diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index 4420ccb2..00000000 --- a/.drone.yml +++ /dev/null @@ -1,13 +0,0 @@ ---- -kind: pipeline -name: fossa - -steps: -- name: fossa - image: rancher/drone-fossa:latest - settings: - api_key: - from_secret: FOSSA_API_KEY - when: - instance: - - drone-publish.rancher.io diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 00000000..6d7f10c5 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,9 @@ +{ + "extends": [ + "github>rancher/renovate-config#release" + ], + "baseBranches": [ + "master" + ], + "prHourlyLimit": 2 +} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..78e6ce1f --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,33 @@ +name: CI + +on: + pull_request: {} + push: + branches: + - master + - release/* + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - name : Checkout repository + # https://github.com/actions/checkout/releases/tag/v4.1.1 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Install Go + # https://github.com/actions/setup-go/releases/tag/v5.0.0 + uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 + with: + go-version-file: 'go.mod' + - name: Install mockgen + run: go install github.com/golang/mock/mockgen@v1.6.0 + - name: Install golangci-lint + uses: golangci/golangci-lint-action@a4f60bb28d35aeee14e6880718e0c85ff1882e64 # v6.0.1 + with: + version: v1.59.0 + - name: Build + run: make build-bin + - name: Test + run: make test + - name: Validate + run: make validate diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml new file mode 100644 index 00000000..abade6bf --- /dev/null +++ b/.github/workflows/renovate.yml @@ -0,0 +1,25 @@ +name: Renovate +on: + workflow_dispatch: + inputs: + logLevel: + description: "Override default log level" + required: false + default: "info" + type: string + overrideSchedule: + description: "Override all schedules" + required: false + default: "false" + type: string + # Run twice in the early morning (UTC) for initial and follow up steps (create pull request and merge) + schedule: + - cron: '30 4,6 * * *' + +jobs: + call-workflow: + uses: rancher/renovate-config/.github/workflows/renovate.yml@release + with: + logLevel: ${{ inputs.logLevel || 'info' }} + overrideSchedule: ${{ github.event.inputs.overrideSchedule == 'true' && '{''schedule'':null}' || '' }} + secrets: inherit diff --git a/.golangci.json b/.golangci.json new file mode 100644 index 00000000..34121c4c --- /dev/null +++ b/.golangci.json @@ -0,0 +1,87 @@ +{ + "linters": { + "disable-all": true, + "enable": [ + "govet", + "revive", + "goimports", + "misspell", + "ineffassign", + "gofmt" + ] + }, + "linters-settings": { + "govet": { + "check-shadowing": false + }, + "gofmt": { + "simplify": false + } + }, + "run": { + "skip-dirs": [ + "vendor", + "tests", + "pkg/client", + "pkg/generated" + ], + "tests": false, + "timeout": "10m" + }, + "issues": { + "exclude-rules": [ + { + "linters": "govet", + "text": "^(nilness|structtag)" + }, + { + "path":"pkg/apis/management.cattle.io/v3/globaldns_types.go", + "text":".*lobalDns.*" + }, + { + "path": "pkg/apis/management.cattle.io/v3/zz_generated_register.go", + "text":".*lobalDns.*" + }, + { + "path":"pkg/apis/management.cattle.io/v3/zz_generated_list_types.go", + "text":".*lobalDns.*" + }, + { + "linters": "revive", + "text": "should have comment" + }, + { + "linters": "revive", + "text": "should be of the form" + }, + { + "linters": "revive", + "text": "by other packages, and that stutters" + }, + { + "linters": "typecheck", + "text": "imported but not used as apierrors" + }, + { + "linters": "revive", + "text": "unused-parameter" + }, + { + "linters": "revive", + "text": "redefines-builtin-id" + }, + { + "linters": "revive", + "text": "superfluous-else" + }, + { + "linters": "revive", + "text": "empty-block" + }, + { + "linters": "revive", + "text": "if-return: redundant if" + } + ] + } +} \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..117d044e --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @rancher/rancher-squad-frameworks diff --git a/Dockerfile b/Dockerfile index 48df490c..8636bf25 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax = docker/dockerfile:experimental -FROM golang:1.13.4 as build +FROM registry.suse.com/bci/golang:1.22 as build COPY go.mod go.sum main.go /src/ COPY pkg /src/pkg/ #RUN --mount=type=cache,target=/root/.cache/go-build \ @@ -7,9 +7,17 @@ RUN \ cd /src && \ CGO_ENABLED=0 go build -ldflags "-extldflags -static -s" -o /steve -FROM alpine -RUN apk -U --no-cache add ca-certificates +FROM registry.suse.com/bci/bci-micro:15.5 + +ARG user=steve + +RUN echo "$user:x:1000:1000::/home/$user:/bin/bash" >> /etc/passwd && \ + echo "$user:x:1000:" >> /etc/group && \ + mkdir /home/$user && \ + chown -R $user:$user /home/$user + COPY --from=build /steve /usr/bin/steve # Hack to make golang do files,dns search order ENV LOCALDOMAIN="" +USER $user ENTRYPOINT ["/usr/bin/steve"] diff --git a/Makefile b/Makefile index e5d99823..5f2ebc16 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,17 @@ build: docker build -t steve . +build-bin: + bash scripts/build-bin.sh + run: build docker run $(DOCKER_ARGS) --rm -p 8989:9080 -it -v ${HOME}/.kube:/root/.kube steve --https-listen-port 0 run-host: build docker run $(DOCKER_ARGS) --net=host --uts=host --rm -it -v ${HOME}/.kube:/root/.kube steve --kubeconfig /root/.kube/config --http-listen-port 8989 --https-listen-port 0 + +test: + bash scripts/test.sh + +validate: + bash scripts/validate.sh \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..22f275be --- /dev/null +++ b/README.md @@ -0,0 +1,749 @@ +steve +===== + +Steve is a lightweight API proxy for Kubernetes whose aim is to create an +interface layer suitable for dashboards to efficiently interact with +Kubernetes. + +API Usage +--------- + +### Kubernetes proxy + +Requests made to `/api`, `/api/*`, `/apis/*`, `/openapi/*` and `/version` will +be proxied directly to Kubernetes. + +### /v1 API + +Steve registers all Kubernetes resources as schemas in the /v1 API. Any +endpoint can support methods GET, POST, PATCH, PUT, or DELETE, depending on +what the underlying Kubernetes endpoint supports and the user's permissions. + +* `/v1/{type}` - all cluster-scoped resources OR all resources in all + namespaces of type `{type}` that the user has access to +* `/v1/{type}/{name}` - cluster-scoped resource of type `{type}` and unique name `{name}` +* `/v1/{type}/{namespace}` - all resources of type `{type}` under namespace `{namespace}` +* `/v1/{type}/{namespace}/{name}` - resource of type `{type}` under namespace + `{namespace}` with name `{name}` unique within the namespace + +### Query parameters + +Steve supports query parameters to perform actions or process data on top of +what Kubernetes supports. In-depth, auto-generated API examples can be found in +[rancher](https://github.com/rancher/rancher/tree/release/v2.8/tests/v2/integration/steveapi#api-examples). + +#### `link` + +Trigger a link handler, which is registered with the schema. Examples are +calling the shell for a cluster, or following logs during cluster or catalog +operations: + +``` +GET /v1/management.cattle.io.clusters/local?link=log +``` + +#### `action` + +Trigger an action handler, which is registered with the schema. Examples are +generating a kubeconfig for a cluster, or installing an app from a catalog: + +``` +POST /v1/catalog.cattle.io.clusterrepos/rancher-partner-charts?action=install +``` + +#### `limit` + +Only applicable to list requests (`/v1/{type}` and `/v1/{type}/{namespace}`). + +Set the maximum number of results to retrieve from Kubernetes. The limit is +passed on as a parameter to the Kubernetes request. The purpose of setting this +limit is to prevent a huge response from overwhelming Steve and Rancher. For +more information about setting limits, review the Kubernetes documentation on +[retrieving results in +chunks](https://kubernetes.io/docs/reference/using-api/api-concepts/#retrieving-large-results-sets-in-chunks). + +The limit controls the size of the set coming from Kubernetes, and then +filtering, sorting, and pagination are applied on that set. Because of this, if +the result set is partial, there is no guarantee that the result returned to +the client is fully sorted across the entire list, only across the returned +chunk. + +The returned response will include a `continue` token, which indicates that the +result is partial and must be used in the subsequent request to retrieve the +next chunk. + +The default limit is 100000. To override the default, set `limit=-1`. + +#### `continue` + +Only applicable to list requests (`/v1/{type}` and `/v1/{type}/{namespace}`). + +Continue retrieving the next chunk of a partial list. The continue token is +included in the response of a limited list and indicates that the result is +partial. This token can then be used as a query parameter to retrieve the next +chunk. All chunks have been retrieved when the continue field in the response +is empty. + +#### `filter` + +Only applicable to list requests (`/v1/{type}` and `/v1/{type}/{namespace}`). + +Filter results by a designated field. Filter keys use dot notation to denote +the subfield of an object to filter on. The filter value is matched as a +substring. + +Example, filtering by object name: + +``` +/v1/{type}?filter=metadata.name=foo +``` + +One filter can list multiple possible fields to match, these are ORed together: + +``` +/v1/{type}?filter=metadata.name=foo,metadata.namespace=foo +``` + +Stacked filters are ANDed together, so an object must match all filters to be +included in the list. + +``` +/v1/{type}?filter=metadata.name=foo&filter=metadata.namespace=bar +``` + +Filters can be negated to exclude results: + +``` +/v1/{type}?filter=metadata.name!=foo +``` + +Arrays are searched for matching items. If any item in the array matches, the +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 running 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}`). + +Results can be sorted lexicographically by primary and secondary columns. + +Sorting by only a primary column, for example name: + +``` +/v1/{type}?sort=metadata.name +``` + +Reverse sorting by name: + +``` +/v1/{type}?sort=-metadata.name +``` + +The secondary sort criteria is comma separated. + +Example, sorting by name and creation time in ascending order: + +``` +/v1/{type}?sort=metadata.name,metadata.creationTimestamp +``` + +Reverse sort by name, normal sort by creation time: + +``` +/v1/{type}?sort=-metadata.name,metadata.creationTimestamp +``` + +Normal sort by name, reverse sort by creation time: + +``` +/v1/{type}?sort=metadata.name,-metadata.creationTimestamp +``` + +#### `page`, `pagesize`, and `revision` + +Only applicable to list requests (`/v1/{type}` and `/v1/{type}/{namespace}`). + +Results can be batched by pages for easier display. + +Example initial request returning a page with 10 results: + +``` +/v1/{type}?pagesize=10 +``` + +Pages are one-indexed, so this is equivalent to + +``` +/v1/{type}?pagesize=10&page=1 +``` +To retrieve subsequent pages, the page number and the list revision number must +be included in the request. This ensures the page will be retrieved from the +cache, rather than making a new request to Kubernetes. If the revision number +is omitted, a new fetch is performed in order to get the latest revision. The +revision is included in the list response. + +``` +/v1/{type}?pagezie=10&page=2&revision=107440 +``` + +The total number of pages and individual items are included in the list +response as `pages` and `count` respectively. + +If a page number is out of bounds, an empty list is returned. + +`page` and `pagesize` can be used alongside the `limit` and `continue` +parameters supported by Kubernetes. `limit` and `continue` are typically used +for server-side chunking and do not guarantee results in any order. + +Running the Steve server +------------------------ + +Steve is typically imported as a library. The calling code starts the server: + +```go +import ( + "fmt" + "context" + + "github.com/rancher/steve/pkg/server" + "github.com/rancher/wrangler/v3/pkg/kubeconfig" +) + +func steve() error { + restConfig, err := kubeconfig.GetNonInteractiveClientConfigWithContext("", "").ClientConfig() + if err != nil { + return err + } + ctx := context.Background() + s, err := server.New(ctx, restConfig, nil) + if err != nil { + return err + } + fmt.Println(s.ListenAndServe(ctx, 9443, 9080, nil)) + return nil +} +``` + +steve can be run directly as a binary for testing. By default it runs on ports 9080 and 9443: + +```sh +export KUBECONFIG=your.cluster +go run main.go +``` + +The API can be accessed by navigating to https://localhost:9443/v1. + +Steve Features +-------------- + +Steve's main use is as an opinionated consumer of +[rancher/apiserver](https://github.com/rancher/apiserver), which it uses to +dynamically register every Kubernetes API as its own. It implements +apiserver +[Stores](https://pkg.go.dev/github.com/rancher/apiserver/pkg/types#Store) to +use Kubernetes as its data store. + +### Stores + +Steve uses apiserver Stores to transform and store data, mainly in Kubernetes. +The main mechanism it uses is the proxy store, which is actually a series of +four nested stores and a "partitioner". It can be instantiated by calling +[NewProxyStore](https://pkg.go.dev/github.com/rancher/steve/pkg/stores/proxy#NewProxyStore). +This gives you: + +* [`proxy.errorStore`](https://github.com/rancher/steve/blob/master/pkg/stores/proxy/error_wrapper.go) - + translates any returned errors into HTTP errors +* [`proxy.WatchRefresh`](https://pkg.go.dev/github.com/rancher/steve/pkg/stores/proxy#WatchRefresh) - + wraps the nested store's Watch method, canceling the watch if access to the + watched resource changes +* [`partition.Store`](https://pkg.go.dev/github.com/rancher/steve/pkg/stores/partition#Store) - + wraps the nested store's List method and parallelizes the request according + to the given partitioner, and additionally implements filtering, sorting, and + pagination on the unstructured data from the nested store +* [`proxy.rbacPartitioner`](https://github.com/rancher/steve/blob/master/pkg/stores/proxy/rbac_store.go) - + the partitioner fed to the `partition.Store` which allows it to parallelize + requests based on the user's access to certain namespaces or resources +* [`proxy.Store`](https://pkg.go.dev/github.com/rancher/steve/pkg/stores/proxy#Store) - + the Kubernetes proxy store which performs the actual connection to Kubernetes + for all operations + +The default schema additionally wraps this proxy store in +[`metrics.Store`](https://pkg.go.dev/github.com/rancher/steve/pkg/stores/metrics#Store), +which records request metrics to Prometheus, by calling +[`metrics.NewMetricsStore`](https://pkg.go.dev/github.com/rancher/steve/pkg/stores/metrics#NewMetricsStore) +on it. + +Steve provides two additional exported stores that are mainly used by Rancher's +[catalogv2](https://github.com/rancher/rancher/tree/release/v2.7/pkg/catalogv2) +package: + +* [`selector.Store`](https://pkg.go.dev/github.com/rancher/steve/pkg/stores/selector#Store) + - wraps the list and watch commands with a label selector +* [`switchschema.Store`](https://pkg.go.dev/github.com/rancher/steve/pkg/stores/switchschema#Store) + - transforms the object's schema + +### Schemas + +Steve watches all Kubernetes API resources, including built-ins, CRDs, and +APIServices, and registers them under its own /v1 endpoint. The component +responsible for watching and registering these schemas is the [schema +controller](https://github.com/rancher/steve/blob/master/pkg/controllers/schema/schemas.go). +Schemas can be queried from the /v1/schemas endpoint. Steve also registers a +few of its own schemas not from Kubernetes to facilitate certain use cases. + +#### [Cluster](https://github.com/rancher/steve/tree/master/pkg/resources/cluster) + +Steve creates a fake local cluster to use in standalone scenarios when there is +not a real +[clusters.management.cattle.io](https://pkg.go.dev/github.com/rancher/rancher/pkg/apis/management.cattle.io/v3#Cluster) +resource available. Rancher overrides this and sets its own customizations on +the cluster resource. + +#### [User Preferences](https://github.com/rancher/steve/tree/master/pkg/resources/userpreferences) + +User preferences in steve provides a way to configure dashboard preferences +through a configuration file named ``prefs.json``. Rancher overrides this and +uses the +[preferences.management.cattle.io](https://pkg.go.dev/github.com/rancher/rancher/pkg/apis/management.cattle.io/v3#Preference) +resource for preference storage instead. + +#### [Counts](https://github.com/rancher/steve/tree/master/pkg/resources/counts) + +Counts keeps track of the number of resources and updates the count in a +buffered stream that the dashboard can subscribe to. + +#### [Subscribe](https://github.com/rancher/apiserver/tree/master/pkg/subscribe) + +Steve exposes a websocket endpoint on /v1/subscribe for sending streams of +events. Connect to the endpoint using a websocket client like websocat: + +```sh +websocat -k wss://127.0.0.1:9443/v1/subscribe +``` + +Review the [apiserver](https://github.com/rancher/apiserver#subscribe) guide +for details. + +In addition to regular Kubernetes resources, steve allows you to subscribe to +special steve resources. For example, to subscribe to counts, send a websocket +message like this: + +``` +{"resourceType":"count"} +``` + +### Schema Templates + +Existing schemas can be customized using schema templates. You can customize +individual schemas or apply customizations to all schemas. + +For example, if you wanted to customize the store for secrets so that secret +data is always redacted, you could implement a store like this: + +```go +import ( + "github.com/rancher/apiserver/pkg/store/empty" + "github.com/rancher/apiserver/pkg/types" +) + +type redactStore struct { + empty.Store // must override the other interface methods as well + // or use a different nested store +} + +func (r *redactStore) ByID(_ *types.APIRequest, _ *types.APISchema, id string) (types.APIObject, error) { + return types.APIObject{ + ID: id, + Object: map[string]string{ + "value": "[redacted]", + }, + }, nil +} + +func (r *redactStore) List(_ *types.APIRequest, _ *types.APISchema) (types.APIObjectList, error) { + return types.APIObjectList{ + Objects: []types.APIObject{ + { + Object: map[string]string{ + "value": "[redacted]", + }, + }, + }, + }, nil +} +``` + +and then create a schema template for the schema with ID "secrets" that uses +that store: + +```go +import ( + "github.com/rancher/steve/pkg/schema" +) + +template := schema.Template{ + ID: "secret", + Store: &redactStore{}, +} +``` + +You could specify the same by providing the group and kind: + +```go +template := schema.Template{ + Group: "", // core resources have an empty group + Kind: "secret", + Store: &redactStore{}, +} +``` + +then add the template to the schema factory: + +```go +schemaFactory.AddTemplate(template) +``` + +As another example, if you wanted to add a custom field to all objects in a +collection response, you can add a schema template with a collection formatter +to omit the ID or the group and kind: + +```go +template := schema.Template{ + Customize: func(schema *types.APISchema) { + schema.CollectionFormatter = func(apiOp *types.APIRequest, collection *types.GenericCollection) { + for _, d := range collection.Data { + obj := d.APIObject.Object.(*unstructured.Unstructured) + obj.Object["tag"] = "custom" + } + } + } +} +``` + +### Schema Access Control + +Steve implements access control on schemas based on the user's RBAC in +Kubernetes. + +The apiserver +[`Server`](https://pkg.go.dev/github.com/rancher/apiserver/pkg/server#Server) +object exposes an AccessControl field which is used to customize how access +control is performed on server requests. + +An +[`accesscontrol.AccessStore`](https://pkg.go.dev/github.com/rancher/steve/pkg/accesscontrol#AccessStore) +is stored on the schema factory. When a user makes any request, the request +handler first finds all the schemas that are available to the user. To do this, +it first retrieves an +[`accesscontrol.AccessSet`](https://pkg.go.dev/github.com/rancher/steve/pkg/accesscontrol#AccessSet) +by calling +[`AccessFor`](https://pkg.go.dev/github.com/rancher/steve/pkg/accesscontrol#AccessStore.AccessFor) +on the user. The AccessSet contains a map of resources and the verbs that can +be used on them. The AccessSet is calculated by looking up all of the user's +role bindings and cluster role bindings for the user's name and group. The +result is cached, and the cached result is used until the user's role +assignments change. Once the AccessSet is retrieved, each registered schema is +checked for existence in the AccessSet, and filtered out if it is not +available. + +This final set of schemas is inserted into the +[`types.APIRequest`](https://pkg.go.dev/github.com/rancher/apiserver/pkg/types#APIRequest) +object and passed to the apiserver handler. + +### Authentication + +Steve authenticates incoming requests using a customizable authentication +middleware. The default authenticator in standalone steve is the +[AlwaysAdmin](https://pkg.go.dev/github.com/rancher/steve/pkg/auth#AlwaysAdmin) +middleware, which accepts all incoming requests and sets admin attributes on +the user. The authenticator can be overridden by passing a custom middleware to +the steve server: + +```go +import ( + "context" + "github.com/rancher/steve/pkg/server" + "github.com/rancher/steve/pkg/auth" + "k8s.io/apiserver/pkg/authentication/user" +) + +func run() { + restConfig := getRestConfig() + authenticator := func (req *http.Request) (user.Info, bool, error) { + username, password, ok := req.BasicAuth() + if !ok { + return nil, false, nil + } + if username == "hello" && password == "world" { + return &user.DefaultInfo{ + Name: username, + UID: username, + Groups: []string{ + "system:authenticated", + }, + }, true, nil + } + return nil, false, nil + } + server := server.New(context.TODO(), restConfig, &server.Options{ + AuthMiddleware: auth.ToMiddlware(auth.AuthenticatorFunc(authenticator)), + } + server.ListenAndServe(context.TODO(), 9443, 9080, nil) +} +``` + +Once the user is authenticated, if the request is for a Kubernetes resource, +then steve must proxy the request to Kubernetes, so it needs to transform the +request. Steve passes the user Info object from the authenticator to a proxy +handler, either a generic handler or an impersonating handler. The generic +[Handler](https://pkg.go.dev/github.com/rancher/steve/pkg/proxy#Handler) mainly +sets transport options and cleans up the headers on the request in preparation +for forwarding it to Kubernetes. The +[ImpersonatingHandler](https://pkg.go.dev/github.com/rancher/steve/pkg/proxy#ImpersonatingHandler) +uses the user Info object to set Impersonate-* headers on the request, which +Kubernetes uses to decide access. + +### Dashboard + +Steve is designed to be consumed by a graphical user interface and therefore +serves one by default, even in the test server. The default UI is the Rancher +Vue UI hosted on releases.rancher.com. It can be viewed by visiting the running +steve instance on port 9443 in a browser. + +The UI can be enabled and customized by passing options to +[NewUIHandler](https://pkg.go.dev/github.com/rancher/steve/pkg/ui#NewUIHandler). +For example, if you have an alternative index.html file, add the file to +a directory called `./ui`, then create a route that serves a custom UI handler: + +```go +import ( + "net/http" + "github.com/rancher/steve/pkg/ui" + "github.com/gorilla/mux" +) + +func routes() http.Handler { + custom := ui.NewUIHandler(&ui.Options{ + Index: func() string { + return "./ui/index.html" + }, + } + router := mux.NewRouter() + router.Handle("/hello", custom.IndexFile()) + return router +``` + +If no options are set, the UI handler will serve the latest index.html file +from the Rancher Vue UI. + +### Cluster Cache + +The cluster cache keeps watches of all resources with registered schemas. This +is mainly used to update the summary cache and resource counts, but any module +could add a handler to react to any resource change or get cached cluster data. +For example, if we wanted a handler to log all "add" events for newly created +secrets: + +```go +import ( + "context" + "github.com/rancher/steve/pkg/server" + "k8s.io/apimachinery/pkg/runtime" + "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func logSecretEvents(server *server.Server) { + server.ClusterCache.OnAdd(context.TODO(), func(gvk schema.GroupVersionKind, key string, obj runtime.Object) error { + if gvk.Kind == "Secret" { + logrus.Infof("[event] add: %s", key) + } + return nil + }) +} +``` + +### Aggregation + +Rancher uses a concept called "aggregation" to maintain connections to remote +services. Steve implements an aggregation client in order to allow connections +from Rancher and expose its API to Rancher. + +Aggregation is enabled by defining a secret name and namespace in the steve +server: + +```go +import ( + "context" + "github.com/rancher/steve/pkg/server" +) + +func run() { + restConfig := getRestConfig() + server := server.New(context.TODO(), restConfig, &server.Options{ + AggregationSecretNamespace: "cattle-system", + AggregationSecretName: "stv-aggregation", + }) + server.ListenAndServe(context.TODO(), 9443, 9080, nil) +} +``` + +This prompts the steve server to start a controller that watches for this +secret. The secret is expected to contain two pieces of data, a URL and a +token: + +```sh +$ kubectl -n cattle-system get secret stv-aggregation -o yaml +apiVersion: v1 +data: + token: Zm9vYmFy + url: aHR0cHM6Ly8xNzIuMTcuMC4xOjg0NDMvdjMvY29ubmVjdA== +kind: Secret +metadata: +... +``` + +Steve makes a websocket connection to the URL using the token to authenticate. +When the secret changes, the steve aggregation server restarts with the +up-to-date URL and token. + +Through this websocket connection, the steve agent is exposed on the remote +management server and the management server can route steve requests to it. The +management server can also keep track of the availability of the agent by +detecting whether the websocket session is still active. In Rancher, the +connection endpoint runs on /v3/connect. + +Rancher implements aggregation for other types of services as well. In Rancher, +the user can define endpoints via a +[v3.APIService](https://pkg.go.dev/github.com/rancher/rancher/pkg/apis/management.cattle.io/v3#APIService) +custom resource (which is distinct from the built-in Kubernetes +[v1.APIService](https://kubernetes.io/docs/reference/kubernetes-api/cluster-resources/api-service-v1/) +resource). Then Rancher runs a middleware handler that routes incoming requests +to defined endpoints. The external services follow the same process of using a +defined secret containing a URL and token to connect and authenticate to +Rancher. This aggregation is defined independently and does not use steve's +aggregation client. + +### Design of List Processing API + +Steve supports query parameters `filter`, `sort`, `page`/`pagesize`/`revision`, +and `projectsornamespaces` for list requests as described +[above](#query-parameters). These formatting options exist to allow user +interfaces like dashboards to easily consume and display list data in a +friendly way. + +This feature relies on the concept of [stores](#stores) and the RBAC +partitioner. The [proxy +store](https://pkg.go.dev/github.com/rancher/steve/pkg/stores/proxy#Store) +provides raw access to Kubernetes and returns data as an +[unstructured.UnstructuredList](https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1/unstructured#UnstructuredList). +The +[partitioner](https://pkg.go.dev/github.com/rancher/steve/pkg/stores/partition#Partitioner) +calls the +proxy store in parallel for each segment of resources the user has access to, +such as for each namespace. The partitioner feeds the results of each parallelized +request into a stream of +[unstructured.Unstructured](https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1/unstructured#Unstructured). +From here, the list is passed to the +[listprocessor](https://pkg.go.dev/github.com/rancher/steve/pkg/stores/partition/listprocessor) +to filter, sort, and paginate the list. The partition store formats the list as +a +[types.APIObjectList](https://pkg.go.dev/github.com/rancher/apiserver/pkg/types#APIObjectList) +and it is returned up the chain of nested stores. + +Most stores in steve are implementations of the apiserver +[Store](https://pkg.go.dev/github.com/rancher/apiserver/pkg/types#Store) +interface, which returns apiserver +[types](https://pkg.go.dev/github.com/rancher/apiserver/pkg/types). The +partitioner implements its own store type called +[UnstructuredStore](https://pkg.go.dev/github.com/rancher/steve/pkg/stores/partition#UnstructuredStore) +which returns +[unstructured.Unstructured](https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1/unstructured#Unstructured) +objects. The reason for this is that the filtering and sorting functions in the +listprocessor package need to operate on unstructured data because they work on +arbitrary fields. However, it also needs to be run after the parallelized +partitioner has accumulated all the results, because each concurrent fetcher +will only contain partial results. Therefore, the data remains in an +unstructured format until after the listprocessor has been run, then the data +is converted to a structured type. The below diagram illustrates the conversion +sequence. + +![](./docs/store-flow.svg) + +#### Unit tests + +The unit tests for these API features are located in two places: + +##### listprocessor unit tests + +[pkg/stores/partition/listprocessor/processor_test.go](./pkg/stores/partition/listprocessor/processor_test.go) +contains tests for each individual query handler. All changes to +[listprocessor](./pkg/stores/partition/listprocessor/) should include a unit +test in this file. + +##### partition store unit tests + +[pkg/stores/partition/store_test.go](./pkg/stores/partition/store_test.go) +contains tests for the `List` operation of the partition store. This is +especially important for testing the functionality for multiple partitions. It +also tests all supported query parameters, not limited to the +pagination-related ones, and tests them in combination with one another. Tests +should be added here when: + + - the change is related to partitioning + - the change is related to parsing the query parameters + - the change is related to the `limit` or `continue` parameters + - the listprocessor change should be tested with other query parameters + +It doesn't hurt to add a test here for any other listprocessor change. + +Each table test runs several requests, so they are effectively each a bundle of +tests. Each table test has a list of `apiOps` which each specify the request +and the user running it, a list of `access` maps which declares the users +corresponding to each request and controls the +[AccessSet](https://pkg.go.dev/github.com/rancher/steve/pkg/accesscontrol#AccessSet) +the user has, the `partitions` the users have access to, and the `objects` in +each partition. The requests in `apiOps` are run sequentially, and each item in +the lists `want`, `wantCache`, and `wantListCalls` correlate to the expected +results and side effects of each request. `partitions` and `objects` apply to +all requests in the table test. + +#### Integration tests + +Integration tests for the steve API are located among the [rancher integration +tests](ihttps://github.com/rancher/rancher/tree/release/v2.8/tests/v2/integration/steveapi). +See the documentation included there for running the tests and using them to +generate API documentation. diff --git a/docs/store-flow.svg b/docs/store-flow.svg new file mode 100644 index 00000000..9ce7a979 --- /dev/null +++ b/docs/store-flow.svg @@ -0,0 +1,4 @@ + + + +
unstructured.UnstructuredList
unstructured.UnstructuredList
unstructured.UnstructuredList
unstructured.UnstructuredList
unstructured.UnstructuredList
unstructured.UnstructuredList
proxy store
proxy store
unstructured.UnstructuredList
unstructured.UnstructuredList
k8s
k8s
types.APIObjectList
types.APIObjectList
[]unstructured.Unstructured
[]unstructured.Unstructured
partition store
partition store
parallel partition
parallel partition
rbac store
rbac store
parallel partition
parallel partition
rbac store
rbac store
chan []unstructured.Unstructured
chan []unstructured.Unstructured
parallel partition
parallel partition
rbac store
rbac store
[]unstructured.Unstructured
[]unstructured.Unstructured
%3CmxGraphModel%3E%3Croot%3E%3CmxCell%20id%3D%220%22%2F%3E%3CmxCell%20id%3D%221%22%20parent%3D%220%22%2F%3E%3CmxCell%20id%3D%222%22%20value%3D%22%5B%5Dunstructured.Unstructured%22%20style%3D%22edgeLabel%3Bhtml%3D1%3Balign%3Dcenter%3BverticalAlign%3Dmiddle%3Bresizable%3D0%3Bpoints%3D%5B%5D%3B%22%20vertex%3D%221%22%20connectable%3D%220%22%20parent%3D%221%22%3E%3CmxGeometry%20x%3D%22575%22%20y%3D%22360%22%20as%3D%22geometry%22%2F%3E%3C%2FmxCell%3E%3C%2Froot%3E%3C%2FmxGraphModel%3E
%3CmxGraphModel%3E%3Croot%3E%3CmxCell%20id%3D%220%22%2F%3E%3CmxCell%20id%3D%221%22%20parent%3D%220%22%2F%3E%3CmxCell%20id%3D%222%22%20value%3D%22%5B%5Dunstructured.Unstructured%22%20style%3D%22edgeLabel%3Bhtml%3D1%3Balign%3Dcenter%3BverticalAlign%3Dmiddle%3Bresizable%3D0%3Bpoints%3D%5B%5D%3B%22%20vertex%3D%221%22%20connectable%3D%220%22%20parent%3D%221%22%3E%3CmxGeometry%20x%3D%22575%22%20y%3D%22360%22%20as%3D%22geometry%22%2F%3E%3C%2FmxCell%3E%3C%2Froot%3E%3C%2FmxGraphModel%3E
listprocessor
listprocessor
Text is not SVG - cannot display
\ No newline at end of file diff --git a/go.mod b/go.mod index f7b33fec..d9ef5aa4 100644 --- a/go.mod +++ b/go.mod @@ -1,40 +1,125 @@ module github.com/rancher/steve -go 1.13 +go 1.22.0 + +toolchain go1.22.2 replace ( - github.com/crewjam/saml => github.com/rancher/saml v0.0.0-20180713225824-ce1532152fde - github.com/knative/pkg => github.com/rancher/pkg v0.0.0-20190514055449-b30ab9de040e + github.com/crewjam/saml => github.com/rancher/saml v0.2.0 + github.com/knative/pkg => github.com/rancher/pkg v0.0.0-20181214184433-b04c0947ad2f github.com/matryer/moq => github.com/rancher/moq v0.0.0-20190404221404-ee5226d43009 - - k8s.io/client-go => github.com/rancher/client-go v1.24.0-rancher1 ) require ( - github.com/adrg/xdg v0.3.1 - github.com/gorilla/mux v1.8.0 - github.com/gorilla/websocket v1.4.2 - github.com/imdario/mergo v0.3.8 // indirect - github.com/pborman/uuid v1.2.0 + github.com/adrg/xdg v0.4.0 + github.com/golang/mock v1.6.0 + github.com/google/gnostic-models v0.6.8 + github.com/gorilla/mux v1.8.1 + github.com/gorilla/websocket v1.5.1 + github.com/pborman/uuid v1.2.1 github.com/pkg/errors v0.9.1 - github.com/prometheus/client_golang v1.12.1 - github.com/rancher/apiserver v0.0.0-20210922180056-297b6df8d714 - github.com/rancher/dynamiclistener v0.2.1-0.20200714201033-9c1939da3af9 - github.com/rancher/kubernetes-provider-detector v0.1.2 - github.com/rancher/norman v0.0.0-20210423002317-8e6ffc77a819 - github.com/rancher/remotedialer v0.2.6-0.20220104192242-f3837f8d649a - github.com/rancher/wrangler v0.8.11-0.20211214201934-f5aa5d9f2e81 - github.com/sirupsen/logrus v1.8.1 - github.com/stretchr/testify v1.7.0 - github.com/urfave/cli v1.22.2 - github.com/urfave/cli/v2 v2.1.1 - golang.org/x/sync v0.0.0-20210220032951-036812b2e83c - k8s.io/api v0.24.0 - k8s.io/apiextensions-apiserver v0.24.0 - k8s.io/apimachinery v0.24.0 - k8s.io/apiserver v0.24.0 - k8s.io/client-go v12.0.0+incompatible + github.com/prometheus/client_golang v1.16.0 + github.com/rancher/apiserver v0.0.0-20240708202538-39a6f2535146 + github.com/rancher/dynamiclistener v0.6.0-rc2 + github.com/rancher/kubernetes-provider-detector v0.1.5 + github.com/rancher/lasso v0.0.0-20240705194423-b2a060d103c1 + github.com/rancher/norman v0.0.0-20240708202514-a0127673d1b9 + github.com/rancher/remotedialer v0.3.2 + github.com/rancher/wrangler/v3 v3.0.0 + github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.9.0 + github.com/urfave/cli v1.22.14 + github.com/urfave/cli/v2 v2.27.1 + golang.org/x/sync v0.7.0 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.30.1 + k8s.io/apiextensions-apiserver v0.30.1 + k8s.io/apimachinery v0.30.1 + k8s.io/apiserver v0.30.1 + k8s.io/client-go v0.30.1 k8s.io/klog v1.0.0 - k8s.io/kube-aggregator v0.24.0 - k8s.io/kube-openapi v0.0.0-20220328201542-3ee0da9b0b42 + k8s.io/kube-aggregator v0.30.1 + k8s.io/kube-openapi v0.0.0-20240411171206-dc4e619f62f3 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/evanphx/json-patch v5.6.0+incompatible // indirect + github.com/felixge/httpsnoop v1.0.3 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/imdario/mergo v0.3.12 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.4.0 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.10.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0 // indirect + go.opentelemetry.io/otel v1.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 // indirect + go.opentelemetry.io/otel/metric v1.19.0 // indirect + go.opentelemetry.io/otel/sdk v1.19.0 // indirect + go.opentelemetry.io/otel/trace v1.19.0 // indirect + go.opentelemetry.io/proto/otlp v1.0.0 // indirect + golang.org/x/crypto v0.22.0 // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/oauth2 v0.16.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/term v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.3.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect + google.golang.org/grpc v1.58.3 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + k8s.io/component-base v0.30.1 // indirect + k8s.io/klog/v2 v2.120.1 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.49.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/sqlite v1.29.10 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.29.0 // indirect + sigs.k8s.io/cli-utils v0.35.0 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect ) diff --git a/go.sum b/go.sum index bdd8bc4b..606eba72 100644 --- a/go.sum +++ b/go.sum @@ -1,1308 +1,456 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= -cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= -cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/360EntSecGroup-Skylar/excelize v1.4.1/go.mod h1:vnax29X2usfl7HHkBrX5EvSCJcmH3dT9luvxzu8iGAE= -github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= -github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= -github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA= -github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M= -github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= -github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= -github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= -github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= +github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0= +github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA= +github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc= +github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/MakeNowJust/heredoc v0.0.0-20170808103936-bb23615498cd/go.mod h1:64YHyfSL2R96J44Nlwm39UHepQbyR5q10x7iYa1ks2E= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= -github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= -github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= -github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= -github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= -github.com/adrg/xdg v0.3.1 h1:uIyL9BYfXaFgDyVRKE8wjtm6ETQULweQqTofphEFJYY= -github.com/adrg/xdg v0.3.1/go.mod h1:7I2hH/IT30IsupOpKZ5ue7/qNi3CoKzD6tL3HwpaRMQ= -github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= -github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20210826220005-b48c857c3a0e/go.mod h1:F7bn7fEU90QkQ3tnmaTx3LTKLEDqnwWODIYppRQ5hnY= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= -github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= -github.com/benbjohnson/clock v1.0.3/go.mod h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM= -github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= -github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= +github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= -github.com/blang/semver v3.5.0+incompatible h1:CGxCgetQ64DKk7rdZ++Vfnb1+ogGNnB17OJKJXD2Cfs= -github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= -github.com/certifi/gocertifi v0.0.0-20200922220541-2c3bb06c6054/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= -github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5/go.mod h1:/iP1qXHoty45bqomnu2LM+VVyAEdWN+vtSHGlQgyxbw= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= -github.com/cockroachdb/datadriven v0.0.0-20200714090401-bf6692d28da5/go.mod h1:h6jFvWxBdQXxjopDMZyH2UVceIRfR84bdzbkoKrsWNo= -github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoCr5oaCLELYA= -github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI= -github.com/coreos/bbolt v1.3.1-coreos.6/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= -github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= -github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/etcd v3.3.15+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= -github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= -github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/coreos/pkg v0.0.0-20180108230652-97fdf19511ea/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= -github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= -github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/daviddengcn/go-colortext v0.0.0-20160507010035-511bcaf42ccd/go.mod h1:dv4zxwHi5C/8AeI+4gX4dCWOIvNi7I6JCSX0HvlKPgE= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= -github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= -github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/dustmop/soup v1.1.2-0.20190516214245-38228baa104e/go.mod h1:CgNC6SGbT+Xb8wGGvzilttZL1mc5sQ/5KkcxsZttMIk= -github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= -github.com/emicklei/go-restful v2.9.5+incompatible h1:spTtZBk5DYEvbxMVutUuTyh1Ao2r4iyvLdACqsl/Ljk= -github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/evanphx/json-patch v0.0.0-20200808040245-162e5629780b/go.mod h1:NAJj0yf/KaRKURN6nyi7A9IZydMivZEm9oQLWNjfKDc= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= -github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4= -github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= -github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= -github.com/form3tech-oss/jwt-go v3.2.3+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= +github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= +github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= +github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/getkin/kin-openapi v0.76.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg= -github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= -github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= -github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= -github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= -github.com/go-logr/logr v1.2.0 h1:QK40JKJyMdUDz+h+xvCsru/bJhvG0UxvePV0ufL/AcE= -github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/zapr v0.1.0/go.mod h1:tabnROwaDl0UNxkVeFRbY8bwB37GwRv0P8lg6aAiEnk= -github.com/go-logr/zapr v1.2.0/go.mod h1:Qa4Bsj2Vb+FAVeAKsLD8RLQ+YRJB8YDmOAKxaBQf7Ro= -github.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI= -github.com/go-openapi/analysis v0.17.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= -github.com/go-openapi/analysis v0.18.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= -github.com/go-openapi/analysis v0.19.2/go.mod h1:3P1osvZa9jKjb8ed2TPng3f0i/UY9snX6gxi44djMjk= -github.com/go-openapi/analysis v0.19.5/go.mod h1:hkEAkxagaIvIP7VTn8ygJNkd4kAYON2rCu0v0ObL0AU= -github.com/go-openapi/errors v0.17.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= -github.com/go-openapi/errors v0.18.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= -github.com/go-openapi/errors v0.19.2/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= -github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= -github.com/go-openapi/jsonpointer v0.18.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= -github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= -github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= -github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= -github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= -github.com/go-openapi/jsonreference v0.18.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= -github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= -github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= -github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM= -github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg= -github.com/go-openapi/loads v0.17.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= -github.com/go-openapi/loads v0.18.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= -github.com/go-openapi/loads v0.19.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= -github.com/go-openapi/loads v0.19.2/go.mod h1:QAskZPMX5V0C2gvfkGZzJlINuP7Hx/4+ix5jWFxsNPs= -github.com/go-openapi/loads v0.19.4/go.mod h1:zZVHonKd8DXyxyw4yfnVjPzBjIQcLt0CCsn0N0ZrQsk= -github.com/go-openapi/runtime v0.0.0-20180920151709-4f900dc2ade9/go.mod h1:6v9a6LTXWQCdL8k1AO3cvqx5OtZY/Y9wKTgaoP6YRfA= -github.com/go-openapi/runtime v0.19.0/go.mod h1:OwNfisksmmaZse4+gpV3Ne9AyMOlP1lt4sK4FXt0O64= -github.com/go-openapi/runtime v0.19.4/go.mod h1:X277bwSUBxVlCYR3r7xgZZGKVvBd/29gLDlFGtJ8NL4= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc= -github.com/go-openapi/spec v0.17.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= -github.com/go-openapi/spec v0.18.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= -github.com/go-openapi/spec v0.19.2/go.mod h1:sCxk3jxKgioEJikev4fgkNmwS+3kuYdJtcsZsD5zxMY= -github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= -github.com/go-openapi/spec v0.19.5/go.mod h1:Hm2Jr4jv8G1ciIAo+frC/Ft+rR2kQDh8JHKHb3gWUSk= -github.com/go-openapi/strfmt v0.17.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= -github.com/go-openapi/strfmt v0.18.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= -github.com/go-openapi/strfmt v0.19.0/go.mod h1:+uW+93UVvGGq2qGaZxdDeJqSAqBqBdl+ZPMF/cC8nDY= -github.com/go-openapi/strfmt v0.19.3/go.mod h1:0yX7dbo8mKIvc3XSKp7MNfxw4JytCfCD6+bY1AVL9LU= -github.com/go-openapi/strfmt v0.19.5/go.mod h1:eftuHTlB/dI8Uq8JJOyRlieZf+WkkxUuk0dgdHXr2Qk= github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= -github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= -github.com/go-openapi/swag v0.18.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= -github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.19.14 h1:gm3vOOXfiuw5i9p5N9xJvfjvuofpyvLA9Wr6QfK5Fng= -github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= -github.com/go-openapi/validate v0.18.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4= -github.com/go-openapi/validate v0.19.2/go.mod h1:1tRCw7m3jtI8eNWEEliiAqUIcBztB2KDnRCRMUi7GTA= -github.com/go-openapi/validate v0.19.5/go.mod h1:8DJv2CVJQ6kGNpFW6eV9N3JviE1C85nY1c2z52x1Gk4= -github.com/go-openapi/validate v0.19.8/go.mod h1:8DJv2CVJQ6kGNpFW6eV9N3JviE1C85nY1c2z52x1Gk4= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= -github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= +github.com/golang/glog v1.1.0 h1:/d3pCKDPWNnvIWe0vVUpNP32qc8U3PDVxySP/y360qE= +github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20180513044358-24b0969c4cb7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.0.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golangplus/bytes v0.0.0-20160111154220-45c989fe5450/go.mod h1:Bk6SMAONeMXrxql8uvOKuAZSu8aM5RUGv+1C6IJaEho= -github.com/golangplus/fmt v0.0.0-20150411045040-2a5d6d7d2995/go.mod h1:lJgMEyOkYFkPcDKwRXegd+iM6E7matEszMG5HhwytU8= -github.com/golangplus/testing v0.0.0-20180327235837-af21d9c3145e/go.mod h1:0AA//k/eakGydO4jKRoRL2j92ZKSzTgj9tclaCrvXHk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= -github.com/google/cel-go v0.10.1/go.mod h1:U7ayypeSkw23szu4GaQTPJGx66c20mx8JklMSxrmI1w= -github.com/google/cel-spec v0.6.0/go.mod h1:Nwjgxy5CbjlPrtCWjeDjUyKMl8w41YBYGjsyDdqk0xA= -github.com/google/gnostic v0.5.7-v3refs h1:FhTMOKj2VhjpouxvWJAV1TL304uMlb9zcDqkl6cEI54= -github.com/google/gnostic v0.5.7-v3refs/go.mod h1:73MKFl6jIHelAJNaBGFzt3SPtZULs9dYrGFt8OiIsHQ= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= github.com/googleapis/gnostic v0.1.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= -github.com/googleapis/gnostic v0.3.1/go.mod h1:on+2t9HRStVgn95RSsFWFz+6Q0Snyqv1awfrALZdbtU= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= -github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= -github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/go-grpc-middleware v0.0.0-20190222133341-cfaf5686ec79/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= -github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= -github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= -github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.3.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= -github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.3/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= -github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= -github.com/imdario/mergo v0.3.7/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= -github.com/imdario/mergo v0.3.8 h1:CGgOkSJeqMRmt0D9XLWExdT4m4F1vd3FV3VPt+0VxkQ= -github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= -github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= +github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= -github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= -github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= -github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= -github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/maruel/panicparse v0.0.0-20171209025017-c0182c169410/go.mod h1:nty42YY5QByNC5MM7q/nj938VbgPU7avs45z6NClpxI= -github.com/maruel/ut v1.0.0/go.mod h1:I68ffiAt5qre9obEVTy7S2/fj2dJku2NYLvzPuY0gqE= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= -github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= -github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= -github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= -github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180320133207-05fbef0ca5da/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.4.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.11.0 h1:JAKSXpt1YjtLA7YpPiqO9ss6sNXEsPfSGdwN0UHqzrw= github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.14.0 h1:2mOpI4JVVPBN+WQRa0WKH2eXR+Ey+uK4n7Zj0aYpIQA= -github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/ginkgo/v2 v2.15.0 h1:79HwNRBAZHOEwrczrgSOPy+eFTTlIGELKy5as+ClttY= +github.com/onsi/ginkgo/v2 v2.15.0/go.mod h1:HlxMHtYF57y6Dpf+mc5529KKmSq9h2FpCF+/ZkwUxKM= github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= -github.com/onsi/gomega v1.3.0/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= -github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.8.1/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= -github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= -github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/paulmach/orb v0.1.3/go.mod h1:VFlX/8C+IQ1p6FTRRKzKoOPJnvEtA5G0Veuqwbu//Vk= -github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g= -github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/onsi/gomega v1.31.0 h1:54UJxxj6cPInHS3a35wm6BK/F9nHYueZ1NVujHDrnXE= +github.com/onsi/gomega v1.31.0/go.mod h1:DW9aCi7U6Yi40wNVAvT6kzFnEVEI5n3DloYBiKiT6zk= +github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= +github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= -github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= -github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk= -github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4= -github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= -github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= -github.com/qri-io/starlib v0.4.2-0.20200213133954-ff2e8cd5ef8d/go.mod h1:7DPO4domFU579Ga6E61sB9VFNaniPVwJP5C4bBCu3wA= -github.com/rancher/apiserver v0.0.0-20210922180056-297b6df8d714 h1:DJPC3bY+yy8Ne4ge2FYs7k6J0CWERTL47hlKXTCgPYs= -github.com/rancher/apiserver v0.0.0-20210922180056-297b6df8d714/go.mod h1:8W0EwaR9dH5NDFw6mpAX437D0q+EZqKWbZyX71+z2WI= -github.com/rancher/client-go v1.24.0-rancher1 h1:3Hr+QgZRbTo3RF8evVwGd8hn2ZFO6UMUMnRth2CbJcI= -github.com/rancher/client-go v1.24.0-rancher1/go.mod h1:J+jC4WE19J7G2gTyKYvlGroIAdDgUb/Gsr3wyf2SOCQ= -github.com/rancher/dynamiclistener v0.2.1-0.20200714201033-9c1939da3af9 h1:Mo5mPXi7k/TgzMcUIuDpbNxiX2bYh68+yEpaur5Nx80= -github.com/rancher/dynamiclistener v0.2.1-0.20200714201033-9c1939da3af9/go.mod h1:qr0QfhwzcVCR+Ao9WyfnE+jmOpfEAdRhXtNOZGJ3nCQ= -github.com/rancher/kubernetes-provider-detector v0.1.2 h1:iFfmmcZiGya6s3cS4Qxksyqqw5hPbbIDHgKJ2Y44XKM= -github.com/rancher/kubernetes-provider-detector v0.1.2/go.mod h1:ypuJS7kP7rUiAn330xG46mj+Nhvym05GM8NqMVekpH0= -github.com/rancher/lasso v0.0.0-20200515155337-a34e1e26ad91/go.mod h1:G6Vv2aj6xB2YjTVagmu4NkhBvbE8nBcGykHRENH6arI= -github.com/rancher/lasso v0.0.0-20200820172840-0e4cc0ef5cb0/go.mod h1:OhBBBO1pBwYp0hacWdnvSGOj+XE9yMLOLnaypIlic18= -github.com/rancher/lasso v0.0.0-20210616224652-fc3ebd901c08 h1:NxR8Fh0eE7/5/5Zvlog9B5NVjWKqBSb1WYMUF7/IE5c= -github.com/rancher/lasso v0.0.0-20210616224652-fc3ebd901c08/go.mod h1:9qZd/S8DqWzfKtjKGgSoHqGEByYmUE3qRaBaaAHwfEM= -github.com/rancher/moq v0.0.0-20190404221404-ee5226d43009/go.mod h1:wpITyDPTi/Na5h73XkbuEf2AP9fbgrIGqqxVzFhYD6U= -github.com/rancher/norman v0.0.0-20210423002317-8e6ffc77a819 h1:K3pICsdBbzOHoOyWdEjCxd8vApLpk8qwBI5VNCQQsM0= -github.com/rancher/norman v0.0.0-20210423002317-8e6ffc77a819/go.mod h1:hhnf77V2lmZD7cvUqi4vTBpIs3KpHNL/AmuN0MqEClI= -github.com/rancher/remotedialer v0.2.6-0.20220104192242-f3837f8d649a h1:Go8MpBEeZCR0yV1ylu2/KjJBvpYomIezU58pejYCtgk= -github.com/rancher/remotedialer v0.2.6-0.20220104192242-f3837f8d649a/go.mod h1:vq3LvyOFnLcwMiCE1KdW3foPd6g5kAjZOtOb7JqGHck= -github.com/rancher/wrangler v0.6.1/go.mod h1:L4HtjPeX8iqLgsxfJgz+JjKMcX2q3qbRXSeTlC/CSd4= -github.com/rancher/wrangler v0.6.2-0.20200714200521-c61fae623942/go.mod h1:8LdIqAQPHysxNlHqmKbUiDIx9ULt9IHUauh9aOnr67k= -github.com/rancher/wrangler v0.6.2-0.20200820173016-2068de651106/go.mod h1:iKqQcYs4YSDjsme52OZtQU4jHPmLlIiM93aj2c8c/W8= -github.com/rancher/wrangler v0.8.11-0.20211214201934-f5aa5d9f2e81 h1:V6K7b1xEclCmDwfZFYhXHubtTLVpkvYFN+CGOIgIT1M= -github.com/rancher/wrangler v0.8.11-0.20211214201934-f5aa5d9f2e81/go.mod h1:Lte9WjPtGYxYacIWeiS9qawvu2R4NujFU9xuXWJvc/0= -github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uYEyJGbgTkfkS4+E/PavXkNJcbFIpEtjt2B0KDQ5+9M= -github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= -github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= +github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= +github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= +github.com/rancher/apiserver v0.0.0-20240708202538-39a6f2535146 h1:6I4Z7PAGmned9+EYxbMS7kvajId3r8+ZwAR5wB7X3kg= +github.com/rancher/apiserver v0.0.0-20240708202538-39a6f2535146/go.mod h1:ZNk+LcRGwQYHqgbsJijRrI49KFbX31/QzoUBq4rAeV0= +github.com/rancher/dynamiclistener v0.6.0-rc2 h1:ASh61tOKTa2OJyKMc9stcmv7W6Xn/rwA8Me0yEIUe7s= +github.com/rancher/dynamiclistener v0.6.0-rc2/go.mod h1:7VNEQhAwzbYJ08S1MYb6B4vili6K7CcrG4cNZXq1j+s= +github.com/rancher/kubernetes-provider-detector v0.1.5 h1:hWRAsWuJOemzGjz/XrbTlM7QmfO4OedvFE3QwXiH60I= +github.com/rancher/kubernetes-provider-detector v0.1.5/go.mod h1:ypuJS7kP7rUiAn330xG46mj+Nhvym05GM8NqMVekpH0= +github.com/rancher/lasso v0.0.0-20240705194423-b2a060d103c1 h1:vv1jDlYbd4KhGbPNxmjs8CYgEHUrQm2bMtmULfXJ6iw= +github.com/rancher/lasso v0.0.0-20240705194423-b2a060d103c1/go.mod h1:A/y3BLQkxZXYD60MNDRwAG9WGxXfvd6Z6gWR/a8wPw8= +github.com/rancher/norman v0.0.0-20240708202514-a0127673d1b9 h1:AlRMRs5mHJcdiK83KKJyFVeybPMZ7dOUzC0l3k9aUa8= +github.com/rancher/norman v0.0.0-20240708202514-a0127673d1b9/go.mod h1:dyjfXBsNiroPWOdUZe7diUOUSLf6HQ/r2kEpwH/8zas= +github.com/rancher/remotedialer v0.3.2 h1:kstZbRwPS5gPWpGg8VjEHT2poHtArs+Fc317YM8JCzU= +github.com/rancher/remotedialer v0.3.2/go.mod h1:Ys004RpJuTLSm+k4aYUCoFiOOad37ubYev3TkOFg/5w= +github.com/rancher/wrangler/v3 v3.0.0 h1:IHHCA+vrghJDPxjtLk4fmeSCFhNe9fFzLFj3m2B0YpA= +github.com/rancher/wrangler/v3 v3.0.0/go.mod h1:Dfckuuq7MJk2JWVBDywRlZXMxEyPxHy4XqGrPEzu5Eg= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/soheilhy/cmux v0.1.3/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= -github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= -github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= -github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY= -github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= -github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= -github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= -github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo= -github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g= -github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= -github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= -github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= -github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= -github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.2.3-0.20181224173747-660f15d67dbb/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= -github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= -github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= -github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= -github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= -github.com/urfave/cli v1.22.2 h1:gsqYFH8bb9ekPA12kRo0hfjngWQjkJPlN9R0N78BoUo= -github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k= -github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= -github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw= -github.com/xiang90/probing v0.0.0-20160813154853-07dd2e8dfe18/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/xlab/handysort v0.0.0-20150421192137-fb3537ed64a1/go.mod h1:QcJo0QPSfTONNIgpN5RA8prR7fF8nkF6cTWTcNerRO8= -github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca/go.mod h1:ce1O1j6UtZfjr22oyGxGLbauSBp2YVXpARAosm7dHBg= -github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk= +github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA= +github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= +github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= -go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= -go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= -go.etcd.io/etcd/client/v3 v3.5.0/go.mod h1:AIKXXVX/DQXtfTEqBryiLTUXwON+GuvO6Z7lLS/oTh0= -go.etcd.io/etcd/client/v3 v3.5.1/go.mod h1:OnjH4M8OnAotwaB2l9bVgZzRFKru7/ZMoS46OtKyd3Q= -go.etcd.io/etcd/pkg/v3 v3.5.0/go.mod h1:UzJGatBQ1lXChBkQF0AuAtkRQMYnHubxAEYIrC3MSsE= -go.etcd.io/etcd/raft/v3 v3.5.0/go.mod h1:UFOHSIvO/nKwd4lhkwabrTD3cqW5yVyYYf/KlD00Szc= -go.etcd.io/etcd/server/v3 v3.5.0/go.mod h1:3Ah5ruV+M+7RZr0+Y/5mNLwC+eQlni+mQmOVdCRJoS4= -go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= -go.mongodb.org/mongo-driver v1.1.1/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= -go.mongodb.org/mongo-driver v1.1.2/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.opentelemetry.io/contrib v0.20.0 h1:ubFQUn0VCZ0gPwIoJfBJVpeBlyRMxu8Mm/huKWYd9p0= -go.opentelemetry.io/contrib v0.20.0/go.mod h1:G/EtFaa6qaN7+LxqfIAT3GiZa7Wv5DTBUzl5H4LY0Kc= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.20.0/go.mod h1:oVGt1LRbBOBq1A5BQLlUg9UaU/54aiHw8cgjV3aWZ/E= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.20.0 h1:Q3C9yzW6I9jqEc8sawxzxZmY48fs9u220KXq6d5s3XU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.20.0/go.mod h1:2AboqHi0CiIZU0qwhtUfCYD1GeUzvvIXWNkhDt7ZMG4= -go.opentelemetry.io/otel v0.20.0 h1:eaP0Fqu7SXHwvjiqDq83zImeehOHX8doTvU9AwXON8g= -go.opentelemetry.io/otel v0.20.0/go.mod h1:Y3ugLH2oa81t5QO+Lty+zXf8zC9L26ax4Nzoxm/dooo= -go.opentelemetry.io/otel/exporters/otlp v0.20.0 h1:PTNgq9MRmQqqJY0REVbZFvwkYOA85vbdQU/nVfxDyqg= -go.opentelemetry.io/otel/exporters/otlp v0.20.0/go.mod h1:YIieizyaN77rtLJra0buKiNBOm9XQfkPEKBeuhoMwAM= -go.opentelemetry.io/otel/metric v0.20.0 h1:4kzhXFP+btKm4jwxpjIqjs41A7MakRFUS86bqLHTIw8= -go.opentelemetry.io/otel/metric v0.20.0/go.mod h1:598I5tYlH1vzBjn+BTuhzTCSb/9debfNp6R3s7Pr1eU= -go.opentelemetry.io/otel/oteltest v0.20.0 h1:HiITxCawalo5vQzdHfKeZurV8x7ljcqAgiWzF6Vaeaw= -go.opentelemetry.io/otel/oteltest v0.20.0/go.mod h1:L7bgKf9ZB7qCwT9Up7i9/pn0PWIa9FqQ2IQ8LoxiGnw= -go.opentelemetry.io/otel/sdk v0.20.0 h1:JsxtGXd06J8jrnya7fdI/U/MR6yXA5DtbZy+qoHQlr8= -go.opentelemetry.io/otel/sdk v0.20.0/go.mod h1:g/IcepuwNsoiX5Byy2nNV0ySUF1em498m7hBWC279Yc= -go.opentelemetry.io/otel/sdk/export/metric v0.20.0 h1:c5VRjxCXdQlx1HjzwGdQHzZaVI82b5EbBgOu2ljD92g= -go.opentelemetry.io/otel/sdk/export/metric v0.20.0/go.mod h1:h7RBNMsDJ5pmI1zExLi+bJK+Dr8NQCh0qGhm1KDnNlE= -go.opentelemetry.io/otel/sdk/metric v0.20.0 h1:7ao1wpzHRVKf0OQ7GIxiQJA6X7DLX9o14gmVon7mMK8= -go.opentelemetry.io/otel/sdk/metric v0.20.0/go.mod h1:knxiS8Xd4E/N+ZqKmUPf3gTTZ4/0TjTXukfxjzSTpHE= -go.opentelemetry.io/otel/trace v0.20.0 h1:1DL6EXUdcg95gukhuRRvLDO/4X5THh/5dIV52lqtnbw= -go.opentelemetry.io/otel/trace v0.20.0/go.mod h1:6GjCW8zgDjwGHGa6GkyeB8+/5vjT16gUEi0Nf1iBdgw= -go.opentelemetry.io/proto/otlp v0.7.0 h1:rwOQPCuKAKmwGKq2aVNnYIibI6wnV7EvzgfTCzcdGg8= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.starlark.net v0.0.0-20190528202925-30ae18b8564f/go.mod h1:c1/X6cHgvdXj6pUlmWKMkuqRnW4K8x2vwt6JAaaircg= -go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5/go.mod h1:nmDLcffg48OtT/PSW0Hg7FvpRQsQh5OSqIylirxKC7o= -go.uber.org/atomic v0.0.0-20181018215023-8dc6146f7569/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0= -go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= -go.uber.org/multierr v0.0.0-20180122172545-ddea229ff1df/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/zap v0.0.0-20180814183419-67bc79d13d15/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0 h1:KfYpVmrjI7JuToy5k8XV3nkapjWx48k4E4JOtVstzQI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0/go.mod h1:SeQhzAEccGVZVEy7aH87Nh0km+utSpo1pTv6eMMop48= +go.opentelemetry.io/otel v1.19.0 h1:MuS/TNf4/j4IXsZuJegVzI1cwut7Qc00344rgH7p8bs= +go.opentelemetry.io/otel v1.19.0/go.mod h1:i0QyjOq3UPoTzff0PJB2N66fb4S0+rSbSB15/oyH9fY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 h1:3d+S281UTjM+AbF31XSOYn1qXn3BgIdWl8HNEpx08Jk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0/go.mod h1:0+KuTDyKL4gjKCF75pHOX4wuzYDUZYfAQdSu43o+Z2I= +go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPiOKwvpE= +go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8= +go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= +go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= +go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg= +go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo= +go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190617133340-57b3e21c3d56/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220214200702-86341886e292 h1:f+lwQ+GtmgoY+A2YaQxlSOnDjXcQ7ZRLWOHbC6HtRqE= -golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190312203227-4b39c73a6495/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 h1:kQgndtyPBW/JIYERgdxfwMYh3AVStj88WQTlNDi2a+o= -golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180112015858-5ccada7d0a7b/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190320064053-1272bf9dcd53/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190812203447-cdfb69ac37fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= +golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180117170059-2c42eef0765b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191002063906-3421d5a6bb1c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c= -golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20171227012246-e19ae1496984/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= -golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190617190820-da514acc4774/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191017205301-920acffc3e65/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.10-0.20220218145154-897bd77cd717 h1:hI3jKY4Hpf63ns040onEbB3dAkR/H/P83hw1TG8dD3Y= -golang.org/x/tools v0.1.10-0.20220218145154-897bd77cd717/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= +golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gomodules.xyz/jsonpatch/v2 v2.0.1/go.mod h1:IhYNNY4jnS53ZnfE4PAmpKtDpTCj1JFXc+3mwe7XcUU= -gonum.org/v1/gonum v0.0.0-20190331200053-3d26580ed485/go.mod h1:2ltnJ7xHfj0zHS40VVPYEAAMTa3ZGguvHGBSJeRWqE0= -gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= -gonum.org/v1/netlib v0.0.0-20190331212654-76723241ea4e/go.mod h1:kS+toOQn6AQKjmKJ7gzohV1XkqsFehRA2FbsbkopSuQ= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= -google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201102152239-715cce707fb0/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20220107163113-42d7afdf6368 h1:Et6SkiuvnBn+SgrSYXs/BrUpGB4mbdwt4R3vaPIlicA= -google.golang.org/genproto v0.0.0-20220107163113-42d7afdf6368/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5 h1:L6iMMGrtzgHsWofoFcihmDEMYeDR9KN/ThbPWGrh++g= +google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5/go.mod h1:oH/ZOT02u4kWEp7oYBGYFFkCdKS/uYR9Z7+0/xuuFp8= +google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e h1:z3vDksarJxsAKM5dmEGv0GHwE2hKJ096wZra71Vs4sw= +google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:rsr7RhLuwsDKL7RmgDDCUc6yaGr1iqceVb5Wv6f6YvQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.40.0 h1:AGJ0Ih4mHjSeibYkFGh1dD9KJ/eOtZ93I6hoHhukQ5Q= -google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= +google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/inf.v0 v0.9.0/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= -gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= -gopkg.in/yaml.v2 v2.0.0/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200121175148-a6ecf24a6d71/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= -gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= -gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -k8s.io/api v0.0.0-20190918155943-95b840bb6a1f/go.mod h1:uWuOHnjmNrtQomJrvEBg0c0HRNyQ+8KTEERVsK0PW48= -k8s.io/api v0.0.0-20191214185829-ca1d04f8b0d3/go.mod h1:itOjKREfmUTvcjantxOsyYU5mbFsU7qUnyUuRfF5+5M= -k8s.io/api v0.0.0-20220420164651-0bf1867dde52/go.mod h1:qOGElvkvG4iusrwS28JSJgPofbMSCv5PWe0AD3boQGQ= -k8s.io/api v0.17.2/go.mod h1:BS9fjjLc4CMuqfSO8vgbHPKMt5+SF0ET6u/RVDihTo4= k8s.io/api v0.18.0/go.mod h1:q2HRQkfDzHMBZL9l/y9rH63PkQl4vae0xRT+8prbrK8= -k8s.io/api v0.18.8/go.mod h1:d/CXqwWv+Z2XEG1LgceeDmHQwpUJhROPx16SlxJgERY= -k8s.io/api v0.24.0 h1:J0hann2hfxWr1hinZIDefw7Q96wmCBx6SSB8IY0MdDg= -k8s.io/api v0.24.0/go.mod h1:5Jl90IUrJHUJYEMANRURMiVvJ0g7Ax7r3R1bqO8zx8I= -k8s.io/apiextensions-apiserver v0.0.0-20190918161926-8f644eb6e783/go.mod h1:xvae1SZB3E17UpV59AWc271W/Ph25N+bjPyR63X6tPY= -k8s.io/apiextensions-apiserver v0.17.2/go.mod h1:4KdMpjkEjjDI2pPfBA15OscyNldHWdBCfsWMDWAmSTs= -k8s.io/apiextensions-apiserver v0.18.0/go.mod h1:18Cwn1Xws4xnWQNC00FLq1E350b9lUF+aOdIWDOZxgo= -k8s.io/apiextensions-apiserver v0.24.0 h1:JfgFqbA8gKJ/uDT++feAqk9jBIwNnL9YGdQvaI9DLtY= -k8s.io/apiextensions-apiserver v0.24.0/go.mod h1:iuVe4aEpe6827lvO6yWQVxiPSpPoSKVjkq+MIdg84cM= -k8s.io/apimachinery v0.0.0-20190913080033-27d36303b655/go.mod h1:nL6pwRT8NgfF8TT68DBI8uEePRt89cSvoXUVqbkWHq4= -k8s.io/apimachinery v0.0.0-20191214185652-442f8fb2f03a/go.mod h1:Ng1IY8TS7sC44KJxT/WUR6qFRfWwahYYYpNXyYRKOCY= -k8s.io/apimachinery v0.0.0-20191216025728-0ee8b4573e3a/go.mod h1:Ng1IY8TS7sC44KJxT/WUR6qFRfWwahYYYpNXyYRKOCY= -k8s.io/apimachinery v0.0.0-20220331225401-97e5df2d0258/go.mod h1:82Bi4sCzVBdpYjyI4jY6aHX+YCUchUIrZrXKedjd2UM= -k8s.io/apimachinery v0.17.2/go.mod h1:b9qmWdKlLuU9EBh+06BtLcSf/Mu89rWL33naRxs1uZg= +k8s.io/api v0.30.1 h1:kCm/6mADMdbAxmIh0LBjS54nQBE+U4KmbCfIkF5CpJY= +k8s.io/api v0.30.1/go.mod h1:ddbN2C0+0DIiPntan/bye3SW3PdwLa11/0yqwvuRrJM= +k8s.io/apiextensions-apiserver v0.30.1 h1:4fAJZ9985BmpJG6PkoxVRpXv9vmPUOVzl614xarePws= +k8s.io/apiextensions-apiserver v0.30.1/go.mod h1:R4GuSrlhgq43oRY9sF2IToFh7PVlF1JjfWdoG3pixk4= k8s.io/apimachinery v0.18.0/go.mod h1:9SnR/e11v5IbyPCGbvJViimtJ0SwHG4nfZFjU77ftcA= -k8s.io/apimachinery v0.18.8/go.mod h1:6sQd+iHEqmOtALqOFjSWp2KZ9F0wlU/nWm0ZgsYWMig= -k8s.io/apimachinery v0.24.0 h1:ydFCyC/DjCvFCHK5OPMKBlxayQytB8pxy8YQInd5UyQ= -k8s.io/apimachinery v0.24.0/go.mod h1:82Bi4sCzVBdpYjyI4jY6aHX+YCUchUIrZrXKedjd2UM= -k8s.io/apiserver v0.0.0-20190918160949-bfa5e2e684ad/go.mod h1:XPCXEwhjaFN29a8NldXA901ElnKeKLrLtREO9ZhFyhg= -k8s.io/apiserver v0.17.2/go.mod h1:lBmw/TtQdtxvrTk0e2cgtOxHizXI+d0mmGQURIHQZlo= -k8s.io/apiserver v0.18.0/go.mod h1:3S2O6FeBBd6XTo0njUrLxiqk8GNy6wWOftjhJcXYnjw= -k8s.io/apiserver v0.24.0 h1:GR7kGsjOMfilRvlG3Stxv/3uz/ryvJ/aZXc5pqdsNV0= -k8s.io/apiserver v0.24.0/go.mod h1:WFx2yiOMawnogNToVvUYT9nn1jaIkMKj41ZYCVycsBA= -k8s.io/cli-runtime v0.0.0-20191214191754-e6dc6d5c8724/go.mod h1:wzlq80lvjgHW9if6MlE4OIGC86MDKsy5jtl9nxz/IYY= -k8s.io/cli-runtime v0.17.2/go.mod h1:aa8t9ziyQdbkuizkNLAw3qe3srSyWh9zlSB7zTqRNPI= -k8s.io/code-generator v0.0.0-20190912054826-cd179ad6a269/go.mod h1:V5BD6M4CyaN5m+VthcclXWsVcT1Hu+glwa1bi3MIsyE= -k8s.io/code-generator v0.0.0-20191214185510-0b9b3c99f9f2/go.mod h1:BjGKcoq1MRUmcssvHiSxodCco1T6nVIt4YeCT5CMSao= -k8s.io/code-generator v0.17.2/go.mod h1:DVmfPQgxQENqDIzVR2ddLXMH34qeszkKSdH/N+s+38s= -k8s.io/code-generator v0.18.0/go.mod h1:+UHX5rSbxmR8kzS+FAv7um6dtYrZokQvjHpDSYRVkTc= -k8s.io/code-generator v0.24.0/go.mod h1:dpVhs00hTuTdTY6jvVxvTFCk6gSMrtfRydbhZwHI15w= -k8s.io/component-base v0.0.0-20190918160511-547f6c5d7090/go.mod h1:933PBGtQFJky3TEwYx4aEPZ4IxqhWh3R6DCmzqIn1hA= -k8s.io/component-base v0.0.0-20191214190519-d868452632e2/go.mod h1:wupxkh1T/oUDqyTtcIjiEfpbmIHGm8By/vqpSKC6z8c= -k8s.io/component-base v0.17.2/go.mod h1:zMPW3g5aH7cHJpKYQ/ZsGMcgbsA/VyhEugF3QT1awLs= -k8s.io/component-base v0.18.0/go.mod h1:u3BCg0z1uskkzrnAKFzulmYaEpZF7XC9Pf/uFyb1v2c= -k8s.io/component-base v0.24.0 h1:h5jieHZQoHrY/lHG+HyrSbJeyfuitheBvqvKwKHVC0g= -k8s.io/component-base v0.24.0/go.mod h1:Dgazgon0i7KYUsS8krG8muGiMVtUZxG037l1MKyXgrA= +k8s.io/apimachinery v0.30.1 h1:ZQStsEfo4n65yAdlGTfP/uSHMQSoYzU/oeEbkmF7P2U= +k8s.io/apimachinery v0.30.1/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= +k8s.io/apiserver v0.30.1 h1:BEWEe8bzS12nMtDKXzCF5Q5ovp6LjjYkSp8qOPk8LZ8= +k8s.io/apiserver v0.30.1/go.mod h1:i87ZnQ+/PGAmSbD/iEKM68bm1D5reX8fO4Ito4B01mo= +k8s.io/client-go v0.18.0/go.mod h1:uQSYDYs4WhVZ9i6AIoEZuwUggLVEF64HOD37boKAtF8= +k8s.io/client-go v0.30.1 h1:uC/Ir6A3R46wdkgCV3vbLyNOYyCJ8oZnjtJGKfytl/Q= +k8s.io/client-go v0.30.1/go.mod h1:wrAqLNs2trwiCH/wxxmT/x3hKVH9PuV0GGW0oDoHVqc= +k8s.io/component-base v0.30.1 h1:bvAtlPh1UrdaZL20D9+sWxsJljMi0QZ3Lmw+kmZAaxQ= +k8s.io/component-base v0.30.1/go.mod h1:e/X9kDiOebwlI41AvBHuWdqFriSRrX50CdwA9TFaHLI= k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= -k8s.io/gengo v0.0.0-20190822140433-26a664648505/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= -k8s.io/gengo v0.0.0-20200114144118-36b2048a9120/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= -k8s.io/gengo v0.0.0-20210813121822-485abfe95c7c/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= -k8s.io/gengo v0.0.0-20211129171323-c02415ce4185/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= -k8s.io/klog v0.4.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8= k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= -k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= -k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= -k8s.io/klog/v2 v2.60.1 h1:VW25q3bZx9uE3vvdL6M8ezOX79vA2Aq1nEWLqNQclHc= -k8s.io/klog/v2 v2.60.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= -k8s.io/kube-aggregator v0.18.0/go.mod h1:ateewQ5QbjMZF/dihEFXwaEwoA4v/mayRvzfmvb6eqI= -k8s.io/kube-aggregator v0.24.0 h1:ax2B6v5y+sLISgal5COnlDRKOSr97uXpwif6nnK3a/M= -k8s.io/kube-aggregator v0.24.0/go.mod h1:ftfs6Fi46z3cKzeF2kvNBPLbMlSKuqZbesJGNp/cQnw= -k8s.io/kube-openapi v0.0.0-20190816220812-743ec37842bf/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E= -k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-aggregator v0.30.1 h1:ymR2BsxDacTKwzKTuNhGZttuk009c+oZbSeD+IPX5q4= +k8s.io/kube-aggregator v0.30.1/go.mod h1:SFbqWsM6ea8dHd3mPLsZFzJHbjBOS5ykIgJh4znZ5iQ= k8s.io/kube-openapi v0.0.0-20200121204235-bf4fb3bd569c/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E= -k8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E= -k8s.io/kube-openapi v0.0.0-20220328201542-3ee0da9b0b42 h1:Gii5eqf+GmIEwGNKQYQClCayuJCe2/4fZUvF7VG99sU= -k8s.io/kube-openapi v0.0.0-20220328201542-3ee0da9b0b42/go.mod h1:Z/45zLw8lUo4wdiUkI+v/ImEGAvu3WatcZl3lPMR4Rk= -k8s.io/kubectl v0.0.0-20191219154910-1528d4eea6dd/go.mod h1:9ehGcuUGjXVZh0qbYSB0vvofQw2JQe6c6cO0k4wu/Oo= -k8s.io/metrics v0.0.0-20191214191643-6b1944c9f765/go.mod h1:5V7rewilItwK0cz4nomU0b3XCcees2Ka5EBYWS1HBeM= -k8s.io/utils v0.0.0-20190801114015-581e00157fb1/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= -k8s.io/utils v0.0.0-20191114184206-e782cd3c129f/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +k8s.io/kube-openapi v0.0.0-20240411171206-dc4e619f62f3 h1:SbdLaI6mM6ffDSJCadEaD4IkuPzepLDGlkd2xV0t1uA= +k8s.io/kube-openapi v0.0.0-20240411171206-dc4e619f62f3/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= k8s.io/utils v0.0.0-20200324210504-a9aa75ae1b89/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= -k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= -k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 h1:HNSDgDCrr/6Ly3WEGKZftiE7IY19Vz2GdbOCyI4qqhc= -k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= -modernc.org/cc v1.0.0/go.mod h1:1Sk4//wdnYJiUIxnW8ddKpaOJCF37yAdqYnkxUpaYxw= -modernc.org/golex v1.0.0/go.mod h1:b/QX9oBD/LhixY6NDh+IdGv17hgB+51fET1i2kPSmvk= -modernc.org/mathutil v1.0.0/go.mod h1:wU0vUrJsVWBZ4P6e7xtFJEhFSNsfRLJ8H458uRjg03k= -modernc.org/strutil v1.0.0/go.mod h1:lstksw84oURvj9y3tn8lGvRxyRC1S2+g5uuIzNfIOBs= -modernc.org/xc v1.0.0/go.mod h1:mRNCo0bvLjGhHO9WsyuKVU4q0ceiDDDoEeWDJHrNx8I= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.7/go.mod h1:PHgbrJT7lCHcxMU+mDHEm+nx46H4zuuHZkDP6icnhu0= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.30 h1:dUk62HQ3ZFhD48Qr8MIXCiKA8wInBQCtuE4QGfFW7yA= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.30/go.mod h1:fEO7lRTdivWO2qYVCVG7dEADOMo/MLDCVr8So2g88Uw= -sigs.k8s.io/cli-utils v0.16.0 h1:Wr32m1oxjIqc9G9l+igr13PeIM9LCyq8jQ8KjXKelvg= -sigs.k8s.io/cli-utils v0.16.0/go.mod h1:9Jqm9K2W6ShhCxsEuaz6HSRKKOXigPUx3ZfypGgxBLY= -sigs.k8s.io/controller-runtime v0.4.0/go.mod h1:ApC79lpY3PHW9xj/w9pj+lYkLgwAAUZwfXkME1Lajns= -sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 h1:kDi4JBNAsJWfz1aEXhO8Jg87JJaPNLh5tIzYHgStQ9Y= -sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2/go.mod h1:B+TnT182UBxE84DiCz4CVE26eOSDAeYCpfDnC2kdKMY= -sigs.k8s.io/kustomize v2.0.3+incompatible/go.mod h1:MkjgH3RdOWrievjo6c9T245dYlB5QeXV4WCbnt/PEpU= -sigs.k8s.io/kustomize/kyaml v0.4.0/go.mod h1:XJL84E6sOFeNrQ7CADiemc1B0EjIxHo3OhW4o1aJYNw= -sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI= -sigs.k8s.io/structured-merge-diff v0.0.0-20190817042607-6149e4549fca/go.mod h1:IIgPezJWb76P0hotTxzDbWsMYB8APh18qZnxkomBpxA= -sigs.k8s.io/structured-merge-diff v1.0.1-0.20191108220359-b1b620dd3f06 h1:zD2IemQ4LmOcAumeiyDWXKUI2SO0NYDe3H6QGvPOVgU= -sigs.k8s.io/structured-merge-diff v1.0.1-0.20191108220359-b1b620dd3f06/go.mod h1:/ULNhyfzRopfcjskuui0cTITekDduZ7ycKN3oUT9R18= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk= +modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA= +modernc.org/ccgo/v4 v4.16.0/go.mod h1:dkNyWIjFrVIZ68DTo36vHK+6/ShBn4ysU61So6PIqCI= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg= +modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg= +modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.29.0 h1:/U5vjBbQn3RChhv7P11uhYvCSm5G2GaIi5AIGBS6r4c= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.29.0/go.mod h1:z7+wmGM2dfIiLRfrC6jb5kV2Mq/sK1ZP303cxzkV5Y4= +sigs.k8s.io/cli-utils v0.35.0 h1:dfSJaF1W0frW74PtjwiyoB4cwdRygbHnC7qe7HF0g/Y= +sigs.k8s.io/cli-utils v0.35.0/go.mod h1:ITitykCJxP1vaj1Cew/FZEaVJ2YsTN9Q71m02jebkoE= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= sigs.k8s.io/structured-merge-diff/v3 v3.0.0-20200116222232-67a7b8c61874/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw= sigs.k8s.io/structured-merge-diff/v3 v3.0.0/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw= -sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= -sigs.k8s.io/structured-merge-diff/v4 v4.2.1 h1:bKCqE9GvQ5tiVHn5rfn1r+yao3aLQEaLzkkmAkf+A6Y= -sigs.k8s.io/structured-merge-diff/v4 v4.2.1/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4= -sigs.k8s.io/testing_frameworks v0.1.2/go.mod h1:ToQrwSC3s8Xf/lADdZp3Mktcql9CG0UAmdJG9th5i0w= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= -sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= -vbom.ml/util v0.0.0-20160121211510-db5cfe13f5cc/go.mod h1:so/NYdZXCz+E3ZpW0uAoCj6uzU2+8OWDFv/HxUSs7kI= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/main.go b/main.go index 29b7de6e..36529962 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,7 @@ import ( "github.com/rancher/steve/pkg/debug" stevecli "github.com/rancher/steve/pkg/server/cli" "github.com/rancher/steve/pkg/version" - "github.com/rancher/wrangler/pkg/signals" + "github.com/rancher/wrangler/v3/pkg/signals" "github.com/sirupsen/logrus" "github.com/urfave/cli" ) @@ -34,7 +34,7 @@ func main() { func run(_ *cli.Context) error { ctx := signals.SetupSignalContext() debugconfig.MustSetupDebug() - s, err := config.ToServer(ctx) + s, err := config.ToServer(ctx, false) if err != nil { return err } diff --git a/pkg/accesscontrol/access_control.go b/pkg/accesscontrol/access_control.go index fb91a326..083978e3 100644 --- a/pkg/accesscontrol/access_control.go +++ b/pkg/accesscontrol/access_control.go @@ -1,15 +1,15 @@ package accesscontrol import ( - "github.com/rancher/apiserver/pkg/server" + apiserver "github.com/rancher/apiserver/pkg/server" "github.com/rancher/apiserver/pkg/types" "github.com/rancher/steve/pkg/attributes" - "github.com/rancher/wrangler/pkg/kv" + "github.com/rancher/wrangler/v3/pkg/kv" "k8s.io/apimachinery/pkg/runtime/schema" ) type AccessControl struct { - server.SchemaBasedAccess + apiserver.SchemaBasedAccess } func NewAccessControl() *AccessControl { diff --git a/pkg/accesscontrol/access_store.go b/pkg/accesscontrol/access_store.go index c14ee6e6..a4ece860 100644 --- a/pkg/accesscontrol/access_store.go +++ b/pkg/accesscontrol/access_store.go @@ -7,13 +7,16 @@ import ( "sort" "time" - v1 "github.com/rancher/wrangler/pkg/generated/controllers/rbac/v1" + v1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/rbac/v1" "k8s.io/apimachinery/pkg/util/cache" "k8s.io/apiserver/pkg/authentication/user" ) +//go:generate mockgen --build_flags=--mod=mod -package fake -destination fake/AccessSetLookup.go "github.com/rancher/steve/pkg/accesscontrol" AccessSetLookup + type AccessSetLookup interface { AccessFor(user user.Info) *AccessSet + PurgeUserData(id string) } type AccessStore struct { @@ -63,6 +66,10 @@ func (l *AccessStore) AccessFor(user user.Info) *AccessSet { return result } +func (l *AccessStore) PurgeUserData(id string) { + l.cache.Remove(id) +} + func (l *AccessStore) CacheKey(user user.Info) string { d := sha256.New() diff --git a/pkg/accesscontrol/fake/AccessSetLookup.go b/pkg/accesscontrol/fake/AccessSetLookup.go new file mode 100644 index 00000000..41644292 --- /dev/null +++ b/pkg/accesscontrol/fake/AccessSetLookup.go @@ -0,0 +1,62 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/rancher/steve/pkg/accesscontrol (interfaces: AccessSetLookup) + +// Package fake is a generated GoMock package. +package fake + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + accesscontrol "github.com/rancher/steve/pkg/accesscontrol" + user "k8s.io/apiserver/pkg/authentication/user" +) + +// MockAccessSetLookup is a mock of AccessSetLookup interface. +type MockAccessSetLookup struct { + ctrl *gomock.Controller + recorder *MockAccessSetLookupMockRecorder +} + +// MockAccessSetLookupMockRecorder is the mock recorder for MockAccessSetLookup. +type MockAccessSetLookupMockRecorder struct { + mock *MockAccessSetLookup +} + +// NewMockAccessSetLookup creates a new mock instance. +func NewMockAccessSetLookup(ctrl *gomock.Controller) *MockAccessSetLookup { + mock := &MockAccessSetLookup{ctrl: ctrl} + mock.recorder = &MockAccessSetLookupMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAccessSetLookup) EXPECT() *MockAccessSetLookupMockRecorder { + return m.recorder +} + +// AccessFor mocks base method. +func (m *MockAccessSetLookup) AccessFor(arg0 user.Info) *accesscontrol.AccessSet { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AccessFor", arg0) + ret0, _ := ret[0].(*accesscontrol.AccessSet) + return ret0 +} + +// AccessFor indicates an expected call of AccessFor. +func (mr *MockAccessSetLookupMockRecorder) AccessFor(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccessFor", reflect.TypeOf((*MockAccessSetLookup)(nil).AccessFor), arg0) +} + +// PurgeUserData mocks base method. +func (m *MockAccessSetLookup) PurgeUserData(arg0 string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "PurgeUserData", arg0) +} + +// PurgeUserData indicates an expected call of PurgeUserData. +func (mr *MockAccessSetLookupMockRecorder) PurgeUserData(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PurgeUserData", reflect.TypeOf((*MockAccessSetLookup)(nil).PurgeUserData), arg0) +} diff --git a/pkg/accesscontrol/policy_rule_index.go b/pkg/accesscontrol/policy_rule_index.go index b6e0aac2..42395088 100644 --- a/pkg/accesscontrol/policy_rule_index.go +++ b/pkg/accesscontrol/policy_rule_index.go @@ -5,7 +5,7 @@ import ( "hash" "sort" - v1 "github.com/rancher/wrangler/pkg/generated/controllers/rbac/v1" + v1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/rbac/v1" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/runtime/schema" ) diff --git a/pkg/accesscontrol/role_revision_index.go b/pkg/accesscontrol/role_revision_index.go index e6ee05ca..975cc508 100644 --- a/pkg/accesscontrol/role_revision_index.go +++ b/pkg/accesscontrol/role_revision_index.go @@ -4,8 +4,8 @@ import ( "context" "sync" - rbac "github.com/rancher/wrangler/pkg/generated/controllers/rbac/v1" - "github.com/rancher/wrangler/pkg/kv" + rbac "github.com/rancher/wrangler/v3/pkg/generated/controllers/rbac/v1" + "github.com/rancher/wrangler/v3/pkg/kv" rbacv1 "k8s.io/api/rbac/v1" ) diff --git a/pkg/aggregation/watch.go b/pkg/aggregation/watch.go index 263b65d5..4d508519 100644 --- a/pkg/aggregation/watch.go +++ b/pkg/aggregation/watch.go @@ -5,7 +5,7 @@ import ( "context" "net/http" - v1 "github.com/rancher/wrangler/pkg/generated/controllers/core/v1" + v1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/core/v1" "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" ) diff --git a/pkg/attributes/attributes.go b/pkg/attributes/attributes.go index 81be0b34..3ba8f69c 100644 --- a/pkg/attributes/attributes.go +++ b/pkg/attributes/attributes.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/rancher/apiserver/pkg/types" - "github.com/rancher/wrangler/pkg/data/convert" + "github.com/rancher/wrangler/v3/pkg/data/convert" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" ) diff --git a/pkg/auth/cli/webhookcli.go b/pkg/auth/cli/webhookcli.go index e3d9c4d1..8ead8ae5 100644 --- a/pkg/auth/cli/webhookcli.go +++ b/pkg/auth/cli/webhookcli.go @@ -1,12 +1,12 @@ package cli import ( - "k8s.io/client-go/tools/clientcmd" "os" "time" "github.com/rancher/steve/pkg/auth" "github.com/urfave/cli" + "k8s.io/client-go/tools/clientcmd" ) type WebhookConfig struct { diff --git a/pkg/auth/filter.go b/pkg/auth/filter.go index 472366a4..26cfb745 100644 --- a/pkg/auth/filter.go +++ b/pkg/auth/filter.go @@ -2,7 +2,6 @@ package auth import ( "io/ioutil" - "k8s.io/client-go/rest" "net/http" "strings" "time" @@ -13,6 +12,7 @@ import ( "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/plugin/pkg/authenticator/token/webhook" + "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" "k8s.io/client-go/transport" diff --git a/pkg/client/factory.go b/pkg/client/factory.go index 064d2b10..bec38ef1 100644 --- a/pkg/client/factory.go +++ b/pkg/client/factory.go @@ -111,52 +111,52 @@ func (p *Factory) AdminK8sInterface() (kubernetes.Interface, error) { return kubernetes.NewForConfig(p.clientCfg) } -func (p *Factory) DynamicClient(ctx *types.APIRequest) (dynamic.Interface, error) { - return newDynamicClient(ctx, p.clientCfg, p.impersonate) +func (p *Factory) DynamicClient(ctx *types.APIRequest, warningHandler rest.WarningHandler) (dynamic.Interface, error) { + return newDynamicClient(ctx, p.clientCfg, p.impersonate, warningHandler) } -func (p *Factory) Client(ctx *types.APIRequest, s *types.APISchema, namespace string) (dynamic.ResourceInterface, error) { - return newClient(ctx, p.clientCfg, s, namespace, p.impersonate) +func (p *Factory) Client(ctx *types.APIRequest, s *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) { + return newClient(ctx, p.clientCfg, s, namespace, p.impersonate, warningHandler) } -func (p *Factory) AdminClient(ctx *types.APIRequest, s *types.APISchema, namespace string) (dynamic.ResourceInterface, error) { - return newClient(ctx, p.clientCfg, s, namespace, false) +func (p *Factory) AdminClient(ctx *types.APIRequest, s *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) { + return newClient(ctx, p.clientCfg, s, namespace, false, warningHandler) } -func (p *Factory) ClientForWatch(ctx *types.APIRequest, s *types.APISchema, namespace string) (dynamic.ResourceInterface, error) { - return newClient(ctx, p.watchClientCfg, s, namespace, p.impersonate) +func (p *Factory) ClientForWatch(ctx *types.APIRequest, s *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) { + return newClient(ctx, p.watchClientCfg, s, namespace, p.impersonate, warningHandler) } -func (p *Factory) AdminClientForWatch(ctx *types.APIRequest, s *types.APISchema, namespace string) (dynamic.ResourceInterface, error) { - return newClient(ctx, p.watchClientCfg, s, namespace, false) +func (p *Factory) AdminClientForWatch(ctx *types.APIRequest, s *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) { + return newClient(ctx, p.watchClientCfg, s, namespace, false, warningHandler) } -func (p *Factory) TableClient(ctx *types.APIRequest, s *types.APISchema, namespace string) (dynamic.ResourceInterface, error) { +func (p *Factory) TableClient(ctx *types.APIRequest, s *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) { if attributes.Table(s) { - return newClient(ctx, p.tableClientCfg, s, namespace, p.impersonate) + return newClient(ctx, p.tableClientCfg, s, namespace, p.impersonate, warningHandler) } - return p.Client(ctx, s, namespace) + return p.Client(ctx, s, namespace, warningHandler) } -func (p *Factory) TableAdminClient(ctx *types.APIRequest, s *types.APISchema, namespace string) (dynamic.ResourceInterface, error) { +func (p *Factory) TableAdminClient(ctx *types.APIRequest, s *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) { if attributes.Table(s) { - return newClient(ctx, p.tableClientCfg, s, namespace, false) + return newClient(ctx, p.tableClientCfg, s, namespace, false, warningHandler) } - return p.AdminClient(ctx, s, namespace) + return p.AdminClient(ctx, s, namespace, warningHandler) } -func (p *Factory) TableClientForWatch(ctx *types.APIRequest, s *types.APISchema, namespace string) (dynamic.ResourceInterface, error) { +func (p *Factory) TableClientForWatch(ctx *types.APIRequest, s *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) { if attributes.Table(s) { - return newClient(ctx, p.tableWatchClientCfg, s, namespace, p.impersonate) + return newClient(ctx, p.tableWatchClientCfg, s, namespace, p.impersonate, warningHandler) } - return p.ClientForWatch(ctx, s, namespace) + return p.ClientForWatch(ctx, s, namespace, warningHandler) } -func (p *Factory) TableAdminClientForWatch(ctx *types.APIRequest, s *types.APISchema, namespace string) (dynamic.ResourceInterface, error) { +func (p *Factory) TableAdminClientForWatch(ctx *types.APIRequest, s *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) { if attributes.Table(s) { - return newClient(ctx, p.tableWatchClientCfg, s, namespace, false) + return newClient(ctx, p.tableWatchClientCfg, s, namespace, false, warningHandler) } - return p.AdminClientForWatch(ctx, s, namespace) + return p.AdminClientForWatch(ctx, s, namespace, warningHandler) } func setupConfig(ctx *types.APIRequest, cfg *rest.Config, impersonate bool) (*rest.Config, error) { @@ -173,8 +173,9 @@ func setupConfig(ctx *types.APIRequest, cfg *rest.Config, impersonate bool) (*re return cfg, nil } -func newDynamicClient(ctx *types.APIRequest, cfg *rest.Config, impersonate bool) (dynamic.Interface, error) { +func newDynamicClient(ctx *types.APIRequest, cfg *rest.Config, impersonate bool, warningHandler rest.WarningHandler) (dynamic.Interface, error) { cfg, err := setupConfig(ctx, cfg, impersonate) + cfg.WarningHandler = warningHandler if err != nil { return nil, err } @@ -182,8 +183,8 @@ func newDynamicClient(ctx *types.APIRequest, cfg *rest.Config, impersonate bool) return dynamic.NewForConfig(cfg) } -func newClient(ctx *types.APIRequest, cfg *rest.Config, s *types.APISchema, namespace string, impersonate bool) (dynamic.ResourceInterface, error) { - client, err := newDynamicClient(ctx, cfg, impersonate) +func newClient(ctx *types.APIRequest, cfg *rest.Config, s *types.APISchema, namespace string, impersonate bool, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) { + client, err := newDynamicClient(ctx, cfg, impersonate, warningHandler) if err != nil { return nil, err } diff --git a/pkg/clustercache/controller.go b/pkg/clustercache/controller.go index 8da49041..34ffdf1f 100644 --- a/pkg/clustercache/controller.go +++ b/pkg/clustercache/controller.go @@ -8,9 +8,9 @@ import ( "github.com/rancher/apiserver/pkg/types" "github.com/rancher/steve/pkg/attributes" "github.com/rancher/steve/pkg/schema" - "github.com/rancher/wrangler/pkg/merr" - "github.com/rancher/wrangler/pkg/summary/client" - "github.com/rancher/wrangler/pkg/summary/informer" + "github.com/rancher/wrangler/v3/pkg/merr" + "github.com/rancher/wrangler/v3/pkg/summary/client" + "github.com/rancher/wrangler/v3/pkg/summary/informer" "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" diff --git a/pkg/controllers/schema/schemas.go b/pkg/controllers/schema/schemas.go index cd5441c2..0ef75b0b 100644 --- a/pkg/controllers/schema/schemas.go +++ b/pkg/controllers/schema/schemas.go @@ -11,8 +11,8 @@ import ( "github.com/rancher/steve/pkg/resources/common" schema2 "github.com/rancher/steve/pkg/schema" "github.com/rancher/steve/pkg/schema/converter" - apiextcontrollerv1 "github.com/rancher/wrangler/pkg/generated/controllers/apiextensions.k8s.io/v1" - v1 "github.com/rancher/wrangler/pkg/generated/controllers/apiregistration.k8s.io/v1" + apiextcontrollerv1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/apiextensions.k8s.io/v1" + v1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/apiregistration.k8s.io/v1" "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" "golang.org/x/sync/semaphore" @@ -31,8 +31,10 @@ var ( } ) -type SchemasHandler interface { - OnSchemas(schemas *schema2.Collection) error +type SchemasHandlerFunc func(schemas *schema2.Collection) error + +func (s SchemasHandlerFunc) OnSchemas(schemas *schema2.Collection) error { + return s(schemas) } type handler struct { @@ -45,7 +47,7 @@ type handler struct { cols *common.DynamicColumns crd apiextcontrollerv1.CustomResourceDefinitionClient ssar authorizationv1client.SelfSubjectAccessReviewInterface - handler SchemasHandler + handler SchemasHandlerFunc } func Register(ctx context.Context, @@ -54,7 +56,7 @@ func Register(ctx context.Context, crd apiextcontrollerv1.CustomResourceDefinitionController, apiService v1.APIServiceController, ssar authorizationv1client.SelfSubjectAccessReviewInterface, - schemasHandler SchemasHandler, + schemasHandler SchemasHandlerFunc, schemas *schema2.Collection) { h := &handler{ diff --git a/pkg/debounce/refresher.go b/pkg/debounce/refresher.go new file mode 100644 index 00000000..4439ab1d --- /dev/null +++ b/pkg/debounce/refresher.go @@ -0,0 +1,55 @@ +package debounce + +import ( + "context" + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +// Refreshable represents an object which can be refreshed. This should be protected by a mutex for concurrent operation. +type Refreshable interface { + Refresh() error +} + +// DebounceableRefresher is used to debounce multiple attempts to refresh a refreshable type. +type DebounceableRefresher struct { + sync.Mutex + // Refreshable is any type that can be refreshed. The refresh method should by protected by a mutex internally. + Refreshable Refreshable + current context.CancelFunc + onCancel func() +} + +// RefreshAfter requests a refresh after a certain time has passed. Subsequent calls to this method will +// delay the requested refresh by the new duration. Note that this is a total override of the previous calls - calling +// RefreshAfter(time.Second * 2) and then immediately calling RefreshAfter(time.Microsecond * 1) will run a refresh +// in one microsecond +func (d *DebounceableRefresher) RefreshAfter(duration time.Duration) { + d.Lock() + defer d.Unlock() + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + if d.current != nil { + d.current() + } + d.current = cancel + go func() { + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + // this indicates that the context was cancelled. + if d.onCancel != nil { + d.onCancel() + } + case <-timer.C: + // note this can cause multiple refreshes to happen concurrently + err := d.Refreshable.Refresh() + if err != nil { + logrus.Errorf("failed to refresh with error: %v", err) + } + } + }() +} diff --git a/pkg/debounce/refresher_test.go b/pkg/debounce/refresher_test.go new file mode 100644 index 00000000..5c4f17dd --- /dev/null +++ b/pkg/debounce/refresher_test.go @@ -0,0 +1,72 @@ +package debounce + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type refreshable struct { + refreshChannel chan struct{} + cancelChannel chan struct{} + retErr error +} + +func (r *refreshable) Refresh() error { + r.refreshChannel <- struct{}{} + return r.retErr +} + +func (r *refreshable) onCancel() { + r.cancelChannel <- struct{}{} +} + +func TestRefreshAfter(t *testing.T) { + t.Parallel() + refreshChannel := make(chan struct{}, 1) + cancelChannel := make(chan struct{}, 1) + ref := refreshable{ + refreshChannel: refreshChannel, + cancelChannel: cancelChannel, + } + debounce := DebounceableRefresher{ + Refreshable: &ref, + onCancel: ref.onCancel, + } + debounce.RefreshAfter(time.Millisecond * 100) + debounce.RefreshAfter(time.Millisecond * 10) + err := receiveWithTimeout(cancelChannel, time.Second*5) + require.NoError(t, err) + err = receiveWithTimeout(refreshChannel, time.Second*5) + require.NoError(t, err) + close(refreshChannel) + close(cancelChannel) + + // test the error case + refreshChannel = make(chan struct{}, 1) + defer close(refreshChannel) + ref = refreshable{ + retErr: fmt.Errorf("Some error"), + refreshChannel: refreshChannel, + } + debounce = DebounceableRefresher{ + Refreshable: &ref, + } + debounce.RefreshAfter(time.Millisecond * 100) + err = receiveWithTimeout(refreshChannel, time.Second*5) + require.NoError(t, err) +} + +func receiveWithTimeout(channel chan struct{}, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + select { + case <-channel: + return nil + case <-ctx.Done(): + return fmt.Errorf("channel did not recieve value in timeout %d", timeout) + } +} diff --git a/pkg/podimpersonation/podimpersonation.go b/pkg/podimpersonation/podimpersonation.go index 30e3ceda..57a862bf 100644 --- a/pkg/podimpersonation/podimpersonation.go +++ b/pkg/podimpersonation/podimpersonation.go @@ -8,9 +8,9 @@ import ( "time" "github.com/rancher/steve/pkg/stores/proxy" - "github.com/rancher/wrangler/pkg/condition" - "github.com/rancher/wrangler/pkg/randomtoken" - "github.com/rancher/wrangler/pkg/schemas/validation" + "github.com/rancher/wrangler/v3/pkg/condition" + "github.com/rancher/wrangler/v3/pkg/randomtoken" + "github.com/rancher/wrangler/v3/pkg/schemas/validation" "github.com/sirupsen/logrus" v1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -128,10 +128,10 @@ type PodOptions struct { // CreatePod will create a pod with a service account that impersonates as user. Corresponding // ClusterRoles, ClusterRoleBindings, and ServiceAccounts will be create. // IMPORTANT NOTES: -// 1. To ensure this is used securely the namespace assigned to the pod must be a dedicated -// namespace used only for the purpose of running impersonated pods. This is to ensure -// proper protection for the service accounts created. -// 2. The pod must KUBECONFIG env var set to where you expect the kubeconfig to reside +// 1. To ensure this is used securely the namespace assigned to the pod must be a dedicated +// namespace used only for the purpose of running impersonated pods. This is to ensure +// proper protection for the service accounts created. +// 2. The pod must KUBECONFIG env var set to where you expect the kubeconfig to reside func (s *PodImpersonation) CreatePod(ctx context.Context, user user.Info, pod *v1.Pod, podOptions *PodOptions) (*v1.Pod, error) { if podOptions == nil { podOptions = &PodOptions{} @@ -512,10 +512,12 @@ func (s *PodImpersonation) adminKubeConfig(user user.Info, role *rbacv1.ClusterR func (s *PodImpersonation) augmentPod(pod *v1.Pod, sa *v1.ServiceAccount, secret *v1.Secret, imageOverride string) *v1.Pod { var ( - zero = int64(0) - t = true - f = false - m = int32(420) + zero = int64(0) + t = true + f = false + m = int32(0o644) + m2 = int32(0o600) + shellUser = 1000 ) pod = pod.DeepCopy() @@ -535,11 +537,18 @@ func (s *PodImpersonation) augmentPod(pod *v1.Pod, sa *v1.ServiceAccount, secret }, v1.Volume{ Name: "user-kubeconfig", + VolumeSource: v1.VolumeSource{ + EmptyDir: &v1.EmptyDirVolumeSource{}, + }, + }, + v1.Volume{ + Name: "user-kube-configmap", VolumeSource: v1.VolumeSource{ ConfigMap: &v1.ConfigMapVolumeSource{ LocalObjectReference: v1.LocalObjectReference{ Name: s.userConfigName(), }, + DefaultMode: &m2, }, }, }, @@ -553,15 +562,45 @@ func (s *PodImpersonation) augmentPod(pod *v1.Pod, sa *v1.ServiceAccount, secret }, }) + image := imageOverride + if image == "" { + image = s.imageName() + } + for i, container := range pod.Spec.Containers { for _, envvar := range container.Env { if envvar.Name != "KUBECONFIG" { continue } + //This mounts two volumes, one configMap and one emptyDir. + //The reason for this is that we need to change the permissions on the kubeconfig file + //and, since a configMap volume is always read-only, we need an emptyDir volume as well. + vmount := v1.VolumeMount{ + Name: "user-kubeconfig", + MountPath: "/tmp/.kube", + } + cfgVMount := v1.VolumeMount{ + Name: "user-kube-configmap", + MountPath: "/home/.kube/config", + SubPath: "config", + } + + pod.Spec.InitContainers = append(pod.Spec.InitContainers, v1.Container{ + Name: "init-kubeconfig-volume", + Image: image, + Command: []string{"sh", "-c", fmt.Sprintf("cp %s %s && chown %d %s/config", cfgVMount.MountPath, vmount.MountPath, shellUser, vmount.MountPath)}, + ImagePullPolicy: v1.PullIfNotPresent, + SecurityContext: &v1.SecurityContext{ + RunAsUser: &zero, + RunAsGroup: &zero, + }, + VolumeMounts: []v1.VolumeMount{cfgVMount, vmount}, + }, + ) + pod.Spec.Containers[i].VolumeMounts = append(container.VolumeMounts, v1.VolumeMount{ Name: "user-kubeconfig", - ReadOnly: true, MountPath: envvar.Value, SubPath: "config", }) @@ -569,11 +608,6 @@ func (s *PodImpersonation) augmentPod(pod *v1.Pod, sa *v1.ServiceAccount, secret } } - image := imageOverride - if image == "" { - image = s.imageName() - } - pod.Spec.Containers = append(pod.Spec.Containers, v1.Container{ Name: "proxy", Image: image, diff --git a/pkg/podimpersonation/podimpersonation_test.go b/pkg/podimpersonation/podimpersonation_test.go new file mode 100644 index 00000000..1669e005 --- /dev/null +++ b/pkg/podimpersonation/podimpersonation_test.go @@ -0,0 +1,91 @@ +package podimpersonation + +import ( + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "testing" + "time" +) + +func TestAugmentPod(t *testing.T) { + var ( + zero = int64(0) + ) + testCases := []struct { + name string + imageOverride string + envVars []v1.EnvVar + }{ + { + name: "Should mount volume to container, create an init container and use regular image", + imageOverride: "", + envVars: []v1.EnvVar{{Name: "KUBECONFIG", Value: ".kube/config"}}, + }, + { + name: "Should mount volume to container, create an init container and use overridden image", + imageOverride: "rancher/notShell:v1.0.0", + envVars: []v1.EnvVar{{Name: "KUBECONFIG", Value: ".kube/config"}}, + }, + { + name: "Should not create init container if there's no KUBECONFIG envVar", + imageOverride: "", + envVars: []v1.EnvVar{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + p := newPod(tc.envVars) + impersonator := New("", nil, time.Minute, func() string { return "rancher/shell:v0.1.22" }) + pod := impersonator.augmentPod(p, nil, &v1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "s"}}, tc.imageOverride) + + assert.Len(t, pod.Spec.Volumes, len(p.Spec.Volumes)+4, "expected four new volumes") + if len(tc.envVars) != 0 { + assert.Len(t, pod.Spec.Containers[0].VolumeMounts, len(p.Spec.Containers[0].VolumeMounts)+1, "expected kubeconfig volume to be mounted") + assert.Len(t, pod.Spec.InitContainers, len(p.Spec.InitContainers)+1, "expected an init container to be created") + if tc.imageOverride != "" { + assert.Equal(t, pod.Spec.InitContainers[len(pod.Spec.InitContainers)-1].Image, tc.imageOverride, "expected image to be the one received as parameter") + } else { + assert.Equal(t, pod.Spec.InitContainers[len(pod.Spec.InitContainers)-1].Image, impersonator.imageName(), "expected image to be the impersonator image") + } + assert.Equal(t, pod.Spec.InitContainers[len(pod.Spec.InitContainers)-1].SecurityContext.RunAsUser, &zero, "expected init container to run as user zero") + assert.Equal(t, pod.Spec.InitContainers[len(pod.Spec.InitContainers)-1].SecurityContext.RunAsGroup, &zero, "expected init container to run as group zero") + } else { + assert.Len(t, pod.Spec.InitContainers, len(p.Spec.InitContainers), "expected no init container to be created") + } + assert.Equal(t, pod.Spec.Containers[len(pod.Spec.Containers)-1].Name, "proxy", "expected the container proxy to be created") + }) + } +} + +func newPod(env []v1.EnvVar) *v1.Pod { + return &v1.Pod{ + Spec: v1.PodSpec{ + Volumes: []v1.Volume{{ + Name: "volume1", + VolumeSource: v1.VolumeSource{ + ConfigMap: &v1.ConfigMapVolumeSource{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "cfgMap", + }, + }, + }, + }}, + Containers: []v1.Container{ + { + Name: "shell", + Image: "rancher/shell:v0.1.22", + Env: env, + VolumeMounts: []v1.VolumeMount{{ + Name: "volume1", + MountPath: "/home/vol", + }}, + }, + }, + ServiceAccountName: "svc-account-1", + AutomountServiceAccountToken: nil, + SecurityContext: nil, + }, + } +} diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 942644d1..0006e5f3 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -5,7 +5,6 @@ import ( "net/url" "strings" - "github.com/rancher/wrangler/pkg/kubeconfig" "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/util/proxy" "k8s.io/apiserver/pkg/authentication/user" @@ -14,17 +13,6 @@ import ( "k8s.io/client-go/transport" ) -// Mostly copied from "kubectl proxy" code -func HandlerFromConfig(prefix, kubeConfig string) (http.Handler, error) { - loader := kubeconfig.GetInteractiveClientConfig(kubeConfig) - cfg, err := loader.ClientConfig() - if err != nil { - return nil, err - } - - return Handler(prefix, cfg) -} - func ImpersonatingHandler(prefix string, cfg *rest.Config) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { impersonate(rw, req, prefix, cfg) diff --git a/pkg/resources/apigroups/apigroup.go b/pkg/resources/apigroups/apigroup.go index 5bc00825..d40358f6 100644 --- a/pkg/resources/apigroups/apigroup.go +++ b/pkg/resources/apigroups/apigroup.go @@ -7,10 +7,22 @@ import ( "github.com/rancher/apiserver/pkg/store/empty" "github.com/rancher/apiserver/pkg/types" + wschemas "github.com/rancher/wrangler/v3/pkg/schemas" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/discovery" ) +var BaseSchema = types.APISchema{ + Schema: &wschemas.Schema{ + ID: "apigroup", + Attributes: map[string]interface{}{ + "group": "", + "kind": "APIGroup", + "version": "v1", + }, + }, +} + func Template(discovery discovery.DiscoveryInterface) schema.Template { return schema.Template{ ID: "apigroup", diff --git a/pkg/resources/cluster/apply.go b/pkg/resources/cluster/apply.go index c36eb989..3910af63 100644 --- a/pkg/resources/cluster/apply.go +++ b/pkg/resources/cluster/apply.go @@ -11,12 +11,13 @@ import ( "github.com/rancher/steve/pkg/attributes" steveschema "github.com/rancher/steve/pkg/schema" "github.com/rancher/steve/pkg/stores/proxy" - "github.com/rancher/wrangler/pkg/apply" - "github.com/rancher/wrangler/pkg/yaml" + "github.com/rancher/wrangler/v3/pkg/apply" + "github.com/rancher/wrangler/v3/pkg/yaml" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" ) type Apply struct { @@ -110,7 +111,8 @@ func (a *Apply) createApply(apiContext *types.APIRequest) (apply.Apply, error) { } apply := apply.New(client.Discovery(), func(gvr schema.GroupVersionResource) (dynamic.NamespaceableResourceInterface, error) { - dynamicClient, err := a.cg.DynamicClient(apiContext) + // don't record warnings from apply + dynamicClient, err := a.cg.DynamicClient(apiContext, rest.NoWarnings{}) if err != nil { return nil, err } diff --git a/pkg/resources/cluster/cluster.go b/pkg/resources/cluster/cluster.go index 7d5b8abe..3e80e2e2 100644 --- a/pkg/resources/cluster/cluster.go +++ b/pkg/resources/cluster/cluster.go @@ -11,8 +11,8 @@ import ( "github.com/rancher/steve/pkg/attributes" steveschema "github.com/rancher/steve/pkg/schema" "github.com/rancher/steve/pkg/stores/proxy" - "github.com/rancher/wrangler/pkg/genericcondition" - "github.com/rancher/wrangler/pkg/schemas" + "github.com/rancher/wrangler/v3/pkg/genericcondition" + "github.com/rancher/wrangler/v3/pkg/schemas" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" schema2 "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/version" diff --git a/pkg/resources/cluster/cluster_type.go b/pkg/resources/cluster/cluster_type.go index e20387dd..ae4798c8 100644 --- a/pkg/resources/cluster/cluster_type.go +++ b/pkg/resources/cluster/cluster_type.go @@ -1,7 +1,7 @@ package cluster import ( - "github.com/rancher/wrangler/pkg/genericcondition" + "github.com/rancher/wrangler/v3/pkg/genericcondition" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/version" ) diff --git a/pkg/resources/common/dynamiccolumns.go b/pkg/resources/common/dynamiccolumns.go index dfe3af10..6e8f86ea 100644 --- a/pkg/resources/common/dynamiccolumns.go +++ b/pkg/resources/common/dynamiccolumns.go @@ -6,7 +6,7 @@ import ( "github.com/rancher/apiserver/pkg/types" "github.com/rancher/steve/pkg/attributes" - "github.com/rancher/wrangler/pkg/ratelimit" + "github.com/rancher/wrangler/v3/pkg/ratelimit" "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1" diff --git a/pkg/resources/common/formatter.go b/pkg/resources/common/formatter.go index 06dd3450..1bda5986 100644 --- a/pkg/resources/common/formatter.go +++ b/pkg/resources/common/formatter.go @@ -10,9 +10,10 @@ import ( metricsStore "github.com/rancher/steve/pkg/stores/metrics" "github.com/rancher/steve/pkg/stores/proxy" "github.com/rancher/steve/pkg/summarycache" - "github.com/rancher/wrangler/pkg/data" - "github.com/rancher/wrangler/pkg/slice" - "github.com/rancher/wrangler/pkg/summary" + "github.com/rancher/wrangler/v3/pkg/data" + corecontrollers "github.com/rancher/wrangler/v3/pkg/generated/controllers/core/v1" + "github.com/rancher/wrangler/v3/pkg/slice" + "github.com/rancher/wrangler/v3/pkg/summary" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -21,30 +22,50 @@ 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), + } +} + +// DefaultTemplateForStore provides a default schema template which uses a provided, pre-initialized store. Primarily used when creating a Template that uses a Lasso SQL store internally. +func DefaultTemplateForStore(store types.Store, summaryCache *summarycache.SummaryCache) schema.Template { + return schema.Template{ + Store: store, Formatter: formatter(summaryCache), } } func selfLink(gvr schema2.GroupVersionResource, meta metav1.Object) (prefix string) { buf := &strings.Builder{} - if gvr.Group == "" { - buf.WriteString("/api/v1/") - } else { - buf.WriteString("/apis/") + if gvr.Group == "management.cattle.io" && gvr.Version == "v3" { + buf.WriteString("/v1/") buf.WriteString(gvr.Group) - buf.WriteString("/") - buf.WriteString(gvr.Version) - buf.WriteString("/") - } - if meta.GetNamespace() != "" { - buf.WriteString("namespaces/") - buf.WriteString(meta.GetNamespace()) - buf.WriteString("/") + buf.WriteString(".") + buf.WriteString(gvr.Resource) + if meta.GetNamespace() != "" { + buf.WriteString("/") + buf.WriteString(meta.GetNamespace()) + } + } else { + if gvr.Group == "" { + buf.WriteString("/api/v1/") + } else { + buf.WriteString("/apis/") + buf.WriteString(gvr.Group) + buf.WriteString("/") + buf.WriteString(gvr.Version) + buf.WriteString("/") + } + if meta.GetNamespace() != "" { + buf.WriteString("namespaces/") + buf.WriteString(meta.GetNamespace()) + buf.WriteString("/") + } + buf.WriteString(gvr.Resource) } - buf.WriteString(gvr.Resource) buf.WriteString("/") buf.WriteString(meta.GetName()) return buf.String() diff --git a/pkg/resources/common/formatter_test.go b/pkg/resources/common/formatter_test.go index 635fec57..e5a3d600 100644 --- a/pkg/resources/common/formatter_test.go +++ b/pkg/resources/common/formatter_test.go @@ -7,6 +7,7 @@ import ( "github.com/rancher/apiserver/pkg/types" "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + schema2 "k8s.io/apimachinery/pkg/runtime/schema" ) func Test_includeFields(t *testing.T) { @@ -541,3 +542,73 @@ func Test_excludeValues(t *testing.T) { }) } } + +func Test_selfLink(t *testing.T) { + tests := []struct { + name string + group string + version string + resource string + resourceName string + resourceNamespace string + want string + }{ + { + name: "empty group", + group: "", + version: "v1", + resource: "pods", + resourceName: "rancher", + resourceNamespace: "cattle-system", + want: "/api/v1/namespaces/cattle-system/pods/rancher", + }, + { + name: "third party crd", + group: "fake.group.io", + version: "v4", + resource: "new-crd", + resourceName: "new-resource", + resourceNamespace: "random-ns", + want: "/apis/fake.group.io/v4/namespaces/random-ns/new-crd/new-resource", + }, + { + name: "non-namespaced third party crd", + group: "fake.group.io", + version: "v4", + resource: "new-crd", + resourceName: "new-resource", + want: "/apis/fake.group.io/v4/new-crd/new-resource", + }, + { + name: "rancher crd, non namespaced", + group: "management.cattle.io", + version: "v3", + resource: "cluster", + resourceName: "c-123xyz", + want: "/v1/management.cattle.io.cluster/c-123xyz", + }, + { + name: "rancher crd, namespaced", + group: "management.cattle.io", + version: "v3", + resource: "catalogtemplates", + resourceName: "built-in", + resourceNamespace: "cattle-global-data", + want: "/v1/management.cattle.io.catalogtemplates/cattle-global-data/built-in", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + gvr := schema2.GroupVersionResource{ + Group: test.group, + Version: test.version, + Resource: test.resource, + } + obj := unstructured.Unstructured{} + obj.SetName(test.resourceName) + obj.SetNamespace(test.resourceNamespace) + assert.Equal(t, test.want, selfLink(gvr, &obj), "did not get expected prefix for object") + }) + } +} diff --git a/pkg/resources/counts/buffer.go b/pkg/resources/counts/buffer.go index 2552c5df..0f97d14a 100644 --- a/pkg/resources/counts/buffer.go +++ b/pkg/resources/counts/buffer.go @@ -6,35 +6,60 @@ import ( "github.com/rancher/apiserver/pkg/types" ) -func buffer(c chan types.APIEvent) chan types.APIEvent { +// debounceDuration determines how long events will be held before they are sent to the consumer +var debounceDuration = 5 * time.Second + +// countsBuffer creates an APIEvent channel with a buffered response time (i.e. replies are only sent once every second) +func countsBuffer(c chan Count) chan types.APIEvent { result := make(chan types.APIEvent) go func() { defer close(result) - debounce(result, c) + debounceCounts(result, c) }() return result } -func debounce(result, input chan types.APIEvent) { - t := time.NewTicker(time.Second) +// debounceCounts converts counts from an input channel into an APIEvent, and updates the result channel at a reduced pace +func debounceCounts(result chan types.APIEvent, input chan Count) { + // counts aren't a critical value. To avoid excess UI processing, only send updates after debounceDuration has elapsed + t := time.NewTicker(debounceDuration) defer t.Stop() - var ( - lastEvent *types.APIEvent - ) + var currentCount *Count + + firstCount, fOk := <-input + if fOk { + // send a count immediately or we will have to wait a second for the first update + result <- toAPIEvent(firstCount) + } for { select { - case event, ok := <-input: - if ok { - lastEvent = &event - } else { + case count, ok := <-input: + if !ok { return } + if currentCount == nil { + currentCount = &count + } else { + itemCounts := count.Counts + for id, itemCount := range itemCounts { + // our current count will be outdated in comparison with anything in the new events + currentCount.Counts[id] = itemCount + } + } case <-t.C: - if lastEvent != nil { - result <- *lastEvent - lastEvent = nil + if currentCount != nil { + result <- toAPIEvent(*currentCount) + currentCount = nil } } } } + +func toAPIEvent(count Count) types.APIEvent { + return types.APIEvent{ + Name: "resource.change", + ResourceType: "counts", + Object: toAPIObject(count), + } +} diff --git a/pkg/resources/counts/buffer_test.go b/pkg/resources/counts/buffer_test.go new file mode 100644 index 00000000..5c2d71c4 --- /dev/null +++ b/pkg/resources/counts/buffer_test.go @@ -0,0 +1,111 @@ +package counts + +import ( + "fmt" + "strconv" + "testing" + "time" + + "github.com/rancher/apiserver/pkg/types" + "github.com/stretchr/testify/assert" +) + +func Test_countsBuffer(t *testing.T) { + tests := []struct { + name string + numInputEvents int + overrideInput map[int]int // events whose count we should override. Don't include an event >= numInputEvents + }{ + { + name: "test basic input", + numInputEvents: 1, + }, + { + name: "test basic multiple input", + numInputEvents: 3, + }, + { + name: "test basic input which is overriden by later events", + numInputEvents: 3, + overrideInput: map[int]int{ + 1: 17, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + debounceDuration = 10 * time.Millisecond + countsChannel := make(chan Count, 100) + outputChannel := countsBuffer(countsChannel) + + countsChannel <- Count{ + ID: "count", + Counts: map[string]ItemCount{"test": createItemCount(1)}, + } + + // first event is not buffered, so we expect to receive it quicker than the debounce + _, err := receiveWithTimeout(outputChannel, time.Second*1) + assert.NoError(t, err, "Expected first event to be received quickly") + + // stream our standard count events + for i := 0; i < test.numInputEvents; i++ { + countsChannel <- Count{ + ID: "count", + Counts: map[string]ItemCount{strconv.Itoa(i): createItemCount(1)}, + } + } + + // stream any overrides, if applicable + for key, value := range test.overrideInput { + countsChannel <- Count{ + ID: "count", + Counts: map[string]ItemCount{strconv.Itoa(key): createItemCount(value)}, + } + } + + // due to complexities of cycle calculation, give a slight delay for the event to actually stream + output, err := receiveWithTimeout(outputChannel, debounceDuration+time.Millisecond*10) + assert.NoError(t, err, "did not expect an error when receiving value from channel") + outputCount := output.Object.Object.(Count) + assert.Len(t, outputCount.Counts, test.numInputEvents) + for outputID, outputItem := range outputCount.Counts { + outputIdx, err := strconv.Atoi(outputID) + assert.NoError(t, err, "couldn't convert output idx") + nsTotal := 0 + for _, nsSummary := range outputItem.Namespaces { + nsTotal += nsSummary.Count + } + if outputOverride, ok := test.overrideInput[outputIdx]; ok { + assert.Equal(t, outputOverride, outputItem.Summary.Count, "expected overridden output count to be most recent value") + assert.Equal(t, outputOverride, nsTotal, "expected overridden output namespace count to be most recent value") + } else { + assert.Equal(t, 1, outputItem.Summary.Count, "expected non-overridden output count to be 1") + assert.Equal(t, 1, nsTotal, "expected non-overridden output namespace count to be 1") + } + } + }) + } +} + +// receiveWithTimeout tries to get a value from input within duration. Returns an error if no input was received during that period +func receiveWithTimeout(input chan types.APIEvent, duration time.Duration) (*types.APIEvent, error) { + select { + case value := <-input: + return &value, nil + case <-time.After(duration): + return nil, fmt.Errorf("timeout error, no value received after %f seconds", duration.Seconds()) + } +} + +func createItemCount(countTotal int) ItemCount { + return ItemCount{ + Summary: Summary{ + Count: countTotal, + }, + Namespaces: map[string]Summary{ + "test": { + Count: countTotal, + }, + }, + } +} diff --git a/pkg/resources/counts/counts.go b/pkg/resources/counts/counts.go index a44d44b1..78f50e9b 100644 --- a/pkg/resources/counts/counts.go +++ b/pkg/resources/counts/counts.go @@ -10,7 +10,7 @@ import ( "github.com/rancher/steve/pkg/accesscontrol" "github.com/rancher/steve/pkg/attributes" "github.com/rancher/steve/pkg/clustercache" - "github.com/rancher/wrangler/pkg/summary" + "github.com/rancher/wrangler/v3/pkg/summary" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime" schema2 "k8s.io/apimachinery/pkg/runtime/schema" @@ -24,6 +24,7 @@ var ( } ) +// Register registers a new count schema. This schema isn't a true resource but instead returns counts for other resources func Register(schemas *types.APISchemas, ccache clustercache.ClusterCache) { schemas.MustImportAndCustomize(Count{}, func(schema *types.APISchema) { schema.CollectionMethods = []string{http.MethodGet} @@ -110,9 +111,10 @@ func (s *Store) List(apiOp *types.APIRequest, schema *types.APISchema) (types.AP }, nil } +// Watch creates a watch for the Counts schema. This returns only the counts which have changed since the watch was established func (s *Store) Watch(apiOp *types.APIRequest, schema *types.APISchema, w types.WatchRequest) (chan types.APIEvent, error) { var ( - result = make(chan types.APIEvent, 100) + result = make(chan Count, 100) counts map[string]ItemCount gvkToSchema = map[schema2.GroupVersionKind]*types.APISchema{} countLock sync.Mutex @@ -178,18 +180,13 @@ func (s *Store) Watch(apiOp *types.APIRequest, schema *types.APISchema, w types. } counts[schema.ID] = itemCount - countsCopy := map[string]ItemCount{} - for k, v := range counts { - countsCopy[k] = *v.DeepCopy() + changedCount := map[string]ItemCount{ + schema.ID: *itemCount.DeepCopy(), } - result <- types.APIEvent{ - Name: "resource.change", - ResourceType: "counts", - Object: toAPIObject(Count{ - ID: "count", - Counts: countsCopy, - }), + result <- Count{ + ID: "count", + Counts: changedCount, } return nil @@ -205,7 +202,8 @@ func (s *Store) Watch(apiOp *types.APIRequest, schema *types.APISchema, w types. return onChange(false, gvk, key, obj, nil) }) - return buffer(result), nil + // buffer the counts so that we don't spam the consumer with constant updates + return countsBuffer(result), nil } func (s *Store) schemasToWatch(apiOp *types.APIRequest) (result []*types.APISchema) { @@ -280,7 +278,7 @@ func removeSummary(counts Summary, summary summary.Summary) Summary { if counts.States == nil { counts.States = map[string]int{} } - counts.States[simpleState(summary)] -= 1 + counts.States[simpleState(summary)]-- } return counts } @@ -297,7 +295,7 @@ func addSummary(counts Summary, summary summary.Summary) Summary { if counts.States == nil { counts.States = map[string]int{} } - counts.States[simpleState(summary)] += 1 + counts.States[simpleState(summary)]++ } return counts } diff --git a/pkg/resources/counts/counts_test.go b/pkg/resources/counts/counts_test.go new file mode 100644 index 00000000..297bb985 --- /dev/null +++ b/pkg/resources/counts/counts_test.go @@ -0,0 +1,304 @@ +package counts_test + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/rancher/apiserver/pkg/server" + "github.com/rancher/apiserver/pkg/store/empty" + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/steve/pkg/accesscontrol" + "github.com/rancher/steve/pkg/attributes" + "github.com/rancher/steve/pkg/clustercache" + "github.com/rancher/steve/pkg/resources/counts" + "github.com/rancher/steve/pkg/schema" + "github.com/rancher/wrangler/v3/pkg/schemas" + "github.com/rancher/wrangler/v3/pkg/summary" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + schema2 "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + testGroup = "test.k8s.io" + testVersion = "v1" + testResource = "testCRD" + testNotUsedResource = "testNotUsedCRD" + testNewResource = "testNewCRD" +) + +func TestWatch(t *testing.T) { + tests := []struct { + name string + event string // the event to send, can be "add", "remove", or "change" + newSchema bool + countsForSchema int + errDesired bool + }{ + { + name: "add of known schema", + event: "add", + newSchema: false, + countsForSchema: 2, + errDesired: false, + }, + { + name: "add of unknown schema", + event: "add", + newSchema: true, + countsForSchema: 0, + errDesired: true, + }, + { + name: "change of known schema", + event: "change", + newSchema: false, + countsForSchema: 0, + errDesired: true, + }, + { + name: "change of unknown schema", + event: "change", + newSchema: true, + countsForSchema: 0, + errDesired: true, + }, + { + name: "remove of known schema", + event: "remove", + newSchema: false, + countsForSchema: 0, + errDesired: false, + }, + { + name: "remove of unknown schema", + event: "remove", + newSchema: true, + countsForSchema: 0, + errDesired: true, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + testSchema := makeSchema(testResource) + testNotUsedSchema := makeSchema(testNotUsedResource) + testNewSchema := makeSchema(testNewResource) + addGenericPermissionsToSchema(testSchema, "list") + addGenericPermissionsToSchema(testNotUsedSchema, "list") + testSchemas := types.EmptyAPISchemas() + testSchemas.MustAddSchema(*testSchema) + testSchemas.MustAddSchema(*testNotUsedSchema) + testOp := &types.APIRequest{ + Schemas: testSchemas, + AccessControl: &server.SchemaBasedAccess{}, + Request: &http.Request{}, + } + fakeCache := NewFakeClusterCache() + gvk := attributes.GVK(testSchema) + newGVK := attributes.GVK(testNewSchema) + fakeCache.AddSummaryObj(makeSummarizedObject(gvk, "testName1", "testNs", "1")) + counts.Register(testSchemas, fakeCache) + + // next, get the channel our results will be delivered on + countSchema := testSchemas.LookupSchema("count") + // channel will stream our events after we call the handlers to simulate/add/remove/change events + resChannel, err := countSchema.Store.Watch(testOp, nil, types.WatchRequest{}) + assert.NoError(t, err, "got an error when trying to watch counts, did not expect one") + + // call the handlers, triggering the update to receive the event + if test.event == "add" { + var summarizedObject *summary.SummarizedObject + var testGVK schema2.GroupVersionKind + if test.newSchema { + summarizedObject = makeSummarizedObject(newGVK, "testNew", "testNs", "1") + testGVK = newGVK + } else { + summarizedObject = makeSummarizedObject(gvk, "testName2", "testNs", "2") + testGVK = gvk + } + err = fakeCache.addHandler(testGVK, "n/a", summarizedObject) + assert.NoError(t, err, "did not expect error when calling add method") + } else if test.event == "change" { + var summarizedObject *summary.SummarizedObject + var testGVK schema2.GroupVersionKind + var changedSummarizedObject *summary.SummarizedObject + if test.newSchema { + summarizedObject = makeSummarizedObject(newGVK, "testNew", "testNs", "1") + changedSummarizedObject = makeSummarizedObject(newGVK, "testNew", "testNs", "2") + testGVK = newGVK + } else { + summarizedObject = makeSummarizedObject(gvk, "testName1", "testNs", "2") + changedSummarizedObject = makeSummarizedObject(gvk, "testName1", "testNs", "3") + testGVK = gvk + } + err = fakeCache.changeHandler(testGVK, "n/a", changedSummarizedObject, summarizedObject) + assert.NoError(t, err, "did not expect error when calling change method") + } else if test.event == "remove" { + var summarizedObject *summary.SummarizedObject + var testGVK schema2.GroupVersionKind + if test.newSchema { + summarizedObject = makeSummarizedObject(newGVK, "testNew", "testNs", "2") + testGVK = newGVK + } else { + summarizedObject = makeSummarizedObject(gvk, "testName1", "testNs", "2") + testGVK = gvk + } + err = fakeCache.removeHandler(testGVK, "n/a", summarizedObject) + assert.NoError(t, err, "did not expect error when calling add method") + } else { + assert.Failf(t, "unexpected event", "%s is not one of the allowed values of add, change, remove", test.event) + } + // need to call the event handler to force the event to stream + outputCount, err := receiveWithTimeout(resChannel, 100*time.Millisecond) + if test.errDesired { + assert.Errorf(t, err, "expected no value from channel, but got one %+v", outputCount) + } else { + assert.NoError(t, err, "got an error when attempting to get a value from the result channel") + assert.NotNilf(t, outputCount, "expected a new count value, did not get one") + count := outputCount.Object.Object.(counts.Count) + assert.Len(t, count.Counts, 1, "only expected one count event") + itemCount, ok := count.Counts[testResource] + assert.True(t, ok, "expected an item count for %s", testResource) + assert.Equal(t, test.countsForSchema, itemCount.Summary.Count, "expected counts to be correct") + } + }) + } +} + +// receiveWithTimeout tries to get a value from input within duration. Returns an error if no input was received during that period +func receiveWithTimeout(input chan types.APIEvent, duration time.Duration) (*types.APIEvent, error) { + select { + case value := <-input: + return &value, nil + case <-time.After(duration): + return nil, fmt.Errorf("timeout error, no value received after %f seconds", duration.Seconds()) + } +} + +// addGenericPermissions grants the specified verb for all namespaces and all resourceNames +func addGenericPermissionsToSchema(schema *types.APISchema, verb string) { + if verb == "create" { + schema.CollectionMethods = append(schema.CollectionMethods, http.MethodPost) + } else if verb == "get" { + schema.ResourceMethods = append(schema.ResourceMethods, http.MethodGet) + } else if verb == "list" || verb == "watch" { + // list and watch use the same permission checks, so we handle in one case + schema.CollectionMethods = append(schema.CollectionMethods, http.MethodGet, http.MethodPost) + } else if verb == "update" { + schema.ResourceMethods = append(schema.ResourceMethods, http.MethodPut) + } else if verb == "delete" { + schema.ResourceMethods = append(schema.ResourceMethods, http.MethodDelete) + } else { + panic(fmt.Sprintf("Can't add generic permissions for verb %s", verb)) + } + currentAccess := schema.Attributes["access"].(accesscontrol.AccessListByVerb) + currentAccess[verb] = []accesscontrol.Access{ + { + Namespace: "*", + ResourceName: "*", + }, + } +} + +func makeSchema(resourceType string) *types.APISchema { + return &types.APISchema{ + Schema: &schemas.Schema{ + ID: resourceType, + CollectionMethods: []string{}, + ResourceMethods: []string{}, + ResourceFields: map[string]schemas.Field{ + "name": {Type: "string"}, + "value": {Type: "string"}, + }, + Attributes: map[string]interface{}{ + "group": testGroup, + "version": testVersion, + "kind": resourceType, + "resource": resourceType, + "verbs": []string{"get", "list", "watch", "delete", "update", "create"}, + "access": accesscontrol.AccessListByVerb{}, + }, + }, + Store: &empty.Store{}, + } +} + +type fakeClusterCache struct { + summarizedObjects []*summary.SummarizedObject + addHandler clustercache.Handler + removeHandler clustercache.Handler + changeHandler clustercache.ChangeHandler +} + +func NewFakeClusterCache() *fakeClusterCache { + return &fakeClusterCache{ + summarizedObjects: []*summary.SummarizedObject{}, + addHandler: nil, + removeHandler: nil, + changeHandler: nil, + } +} + +func (f *fakeClusterCache) Get(gvk schema2.GroupVersionKind, namespace, name string) (interface{}, bool, error) { + return nil, false, nil +} + +func (f *fakeClusterCache) List(gvk schema2.GroupVersionKind) []interface{} { + var retList []interface{} + for _, summaryObj := range f.summarizedObjects { + if summaryObj.GroupVersionKind() != gvk { + // only list the summary objects for the provided gvk + continue + } + retList = append(retList, summaryObj) + } + return retList +} + +func (f *fakeClusterCache) OnAdd(ctx context.Context, handler clustercache.Handler) { + f.addHandler = handler +} + +func (f *fakeClusterCache) OnRemove(ctx context.Context, handler clustercache.Handler) { + f.removeHandler = handler +} + +func (f *fakeClusterCache) OnChange(ctx context.Context, handler clustercache.ChangeHandler) { + f.changeHandler = handler +} + +func (f *fakeClusterCache) OnSchemas(schemas *schema.Collection) error { + return nil +} + +func (f *fakeClusterCache) AddSummaryObj(summaryObj *summary.SummarizedObject) { + f.summarizedObjects = append(f.summarizedObjects, summaryObj) +} + +func makeSummarizedObject(gvk schema2.GroupVersionKind, name string, namespace string, version string) *summary.SummarizedObject { + apiVersion, kind := gvk.ToAPIVersionAndKind() + return &summary.SummarizedObject{ + Summary: summary.Summary{ + State: "", + Error: false, + Transitioning: false, + }, + PartialObjectMetadata: metav1.PartialObjectMetadata{ + TypeMeta: metav1.TypeMeta{ + APIVersion: apiVersion, + Kind: kind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + ResourceVersion: version, // any non-zero value should work here. 0 seems to have specific meaning for counts + }, + }, + } +} diff --git a/pkg/resources/schema.go b/pkg/resources/schema.go index ff3fee1c..a96e45f7 100644 --- a/pkg/resources/schema.go +++ b/pkg/resources/schema.go @@ -16,15 +16,15 @@ import ( "github.com/rancher/steve/pkg/resources/formatters" "github.com/rancher/steve/pkg/resources/userpreferences" "github.com/rancher/steve/pkg/schema" - 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/v3/pkg/generated/controllers/core/v1" "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/client-go/discovery" ) func DefaultSchemas(ctx context.Context, baseSchema *types.APISchemas, ccache clustercache.ClusterCache, - cg proxy.ClientGetter, schemaFactory steveschema.Factory, serverVersion string) error { + cg proxy.ClientGetter, schemaFactory schema.Factory, serverVersion string) error { counts.Register(baseSchema, ccache) subscribe.Register(baseSchema, func(apiOp *types.APIRequest) *types.APISchemas { user, ok := request.UserFrom(apiOp.Context()) @@ -46,9 +46,40 @@ func DefaultSchemaTemplates(cf *client.Factory, baseSchemas *types.APISchemas, summaryCache *summarycache.SummaryCache, lookup accesscontrol.AccessSetLookup, + discovery discovery.DiscoveryInterface, + namespaceCache corecontrollers.NamespaceCache) []schema.Template { + return []schema.Template{ + common.DefaultTemplate(cf, summaryCache, lookup, namespaceCache), + apigroups.Template(discovery), + { + ID: "configmap", + Formatter: formatters.DropHelmData, + }, + { + ID: "secret", + Formatter: formatters.DropHelmData, + }, + { + ID: "pod", + Formatter: formatters.Pod, + }, + { + ID: "management.cattle.io.cluster", + Customize: func(apiSchema *types.APISchema) { + cluster.AddApply(baseSchemas, apiSchema) + }, + }, + } +} + +// DefaultSchemaTemplatesForStore returns the same default templates as DefaultSchemaTemplates, only using DefaultSchemaTemplateFoStore internally to construct the templates. +func DefaultSchemaTemplatesForStore(store types.Store, + baseSchemas *types.APISchemas, + summaryCache *summarycache.SummaryCache, discovery discovery.DiscoveryInterface) []schema.Template { + return []schema.Template{ - common.DefaultTemplate(cf, summaryCache, lookup), + common.DefaultTemplateForStore(store, summaryCache), apigroups.Template(discovery), { ID: "configmap", diff --git a/pkg/resources/schemas/template.go b/pkg/resources/schemas/template.go index 50b7bb89..b506bb38 100644 --- a/pkg/resources/schemas/template.go +++ b/pkg/resources/schemas/template.go @@ -1,24 +1,26 @@ +// Package schemas handles streaming schema updates and changes. package schemas import ( "context" + "fmt" "sync" "time" "github.com/rancher/apiserver/pkg/builtin" - "k8s.io/apimachinery/pkg/api/equality" - schemastore "github.com/rancher/apiserver/pkg/store/schema" "github.com/rancher/apiserver/pkg/types" "github.com/rancher/steve/pkg/accesscontrol" "github.com/rancher/steve/pkg/schema" - "github.com/rancher/wrangler/pkg/broadcast" - "github.com/rancher/wrangler/pkg/schemas/validation" + "github.com/rancher/wrangler/v3/pkg/broadcast" + "github.com/rancher/wrangler/v3/pkg/schemas/validation" "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/endpoints/request" ) +// SetupWatcher create a new schema.Store for tracking schema changes func SetupWatcher(ctx context.Context, schemas *types.APISchemas, asl accesscontrol.AccessSetLookup, factory schema.Factory) { // one instance shared with all stores notifier := schemaChangeNotifier(ctx, factory) @@ -34,6 +36,7 @@ func SetupWatcher(ctx context.Context, schemas *types.APISchemas, asl accesscont schemas.AddSchema(schema) } +// Store hold information for watching updates to schemas type Store struct { types.Store @@ -42,14 +45,16 @@ type Store struct { schemaChangeNotify func(context.Context) (chan interface{}, error) } -func (s *Store) Watch(apiOp *types.APIRequest, schema *types.APISchema, w types.WatchRequest) (chan types.APIEvent, error) { +// Watch will return a APIevent channel that tracks changes to schemas for a user in a given APIRequest. +// Changes will be returned until Done is closed on the context in the given APIRequest. +func (s *Store) Watch(apiOp *types.APIRequest, _ *types.APISchema, _ types.WatchRequest) (chan types.APIEvent, error) { user, ok := request.UserFrom(apiOp.Request.Context()) if !ok { return nil, validation.Unauthorized } wg := sync.WaitGroup{} - wg.Add(2) + wg.Add(1) result := make(chan types.APIEvent) go func() { @@ -57,30 +62,38 @@ func (s *Store) Watch(apiOp *types.APIRequest, schema *types.APISchema, w types. close(result) }() - go func() { - defer wg.Done() - c, err := s.schemaChangeNotify(apiOp.Context()) - if err != nil { - return - } - schemas, err := s.sf.Schemas(user) - if err != nil { - logrus.Errorf("failed to generate schemas for user %v: %v", user, err) - return - } - for range c { - schemas = s.sendSchemas(result, apiOp, user, schemas) - } - }() + schemas, err := s.sf.Schemas(user) + if err != nil { + return nil, fmt.Errorf("failed to generate schemas for user '%v': %w", user, err) + } + + // Create child contexts that allows us to cancel both change notifications routines. + notifyCtx, notifyCancel := context.WithCancel(apiOp.Context()) + + schemaChangeSignal, err := s.schemaChangeNotify(notifyCtx) + if err != nil { + notifyCancel() + return nil, fmt.Errorf("failed to start schema change notifications: %w", err) + } + + userChangeSignal := s.userChangeNotify(notifyCtx, user) go func() { + defer notifyCancel() defer wg.Done() - schemas, err := s.sf.Schemas(user) - if err != nil { - logrus.Errorf("failed to generate schemas for notify user %v: %v", user, err) - return - } - for range s.userChangeNotify(apiOp.Context(), user) { + + // For each change notification send schema updates onto the result channel. + for { + select { + case _, ok := <-schemaChangeSignal: + if !ok { + return + } + case _, ok := <-userChangeSignal: + if !ok { + return + } + } schemas = s.sendSchemas(result, apiOp, user, schemas) } }() @@ -88,7 +101,9 @@ func (s *Store) Watch(apiOp *types.APIRequest, schema *types.APISchema, w types. return result, nil } +// sendSchemas will send APIEvents onto the provided result channel based on detected changes in the schemas for the provided users. func (s *Store) sendSchemas(result chan types.APIEvent, apiOp *types.APIRequest, user user.Info, oldSchemas *types.APISchemas) *types.APISchemas { + // get the current schemas for a user schemas, err := s.sf.Schemas(user) if err != nil { logrus.Errorf("failed to get schemas for %v: %v", user, err) @@ -96,9 +111,15 @@ func (s *Store) sendSchemas(result chan types.APIEvent, apiOp *types.APIRequest, } inNewSchemas := map[string]bool{} - for _, apiObject := range schemastore.FilterSchemas(apiOp, schemas.Schemas).Objects { + + // Convert the schemas for the given user to a flat list of APIObjects. + apiObjects := schemastore.FilterSchemas(apiOp, schemas.Schemas).Objects + for i := range apiObjects { + apiObject := apiObjects[i] inNewSchemas[apiObject.ID] = true eventName := types.ChangeAPIEvent + + // Check to see if the schema represented by the current APIObject exist in the oldSchemas. if oldSchema := oldSchemas.LookupSchema(apiObject.ID); oldSchema == nil { eventName = types.CreateAPIEvent } else { @@ -106,10 +127,15 @@ func (s *Store) sendSchemas(result chan types.APIEvent, apiOp *types.APIRequest, oldSchemaCopy := oldSchema.Schema.DeepCopy() newSchemaCopy.Mapper = nil oldSchemaCopy.Mapper = nil + + // APIObjects are intentionally stripped of access information. Thus we will remove the field when comparing changes. + delete(oldSchemaCopy.Attributes, "access") if equality.Semantic.DeepEqual(newSchemaCopy, oldSchemaCopy) { continue } } + + // Send the new or modified schema as an APIObject on the APIEvent channel. result <- types.APIEvent{ Name: eventName, ResourceType: "schema", @@ -117,7 +143,10 @@ func (s *Store) sendSchemas(result chan types.APIEvent, apiOp *types.APIRequest, } } - for _, oldSchema := range schemastore.FilterSchemas(apiOp, oldSchemas.Schemas).Objects { + // Identify all of the oldSchema APIObjects that have been removed and send Remove APIEvents. + oldSchemaObjs := schemastore.FilterSchemas(apiOp, oldSchemas.Schemas).Objects + for i := range oldSchemaObjs { + oldSchema := oldSchemaObjs[i] if inNewSchemas[oldSchema.ID] { continue } @@ -131,6 +160,9 @@ func (s *Store) sendSchemas(result chan types.APIEvent, apiOp *types.APIRequest, return schemas } +// userChangeNotify gets the provided users AccessSet every 2 seconds. +// If the AccessSet has changed the caller is notified via an empty struct sent on the returned channel. +// If the given context is finished then the returned channel will be closed. func (s *Store) userChangeNotify(ctx context.Context, user user.Info) chan interface{} { as := s.asl.AccessFor(user) result := make(chan interface{}) @@ -154,6 +186,7 @@ func (s *Store) userChangeNotify(ctx context.Context, user user.Info) chan inter return result } +// schemaChangeNotifier returns a channel that is used to signal OnChange was called for the provided factory. func schemaChangeNotifier(ctx context.Context, factory schema.Factory) func(ctx context.Context) (chan interface{}, error) { notify := make(chan interface{}) bcast := &broadcast.Broadcaster{} diff --git a/pkg/resources/schemas/template_test.go b/pkg/resources/schemas/template_test.go new file mode 100644 index 00000000..4a1bb916 --- /dev/null +++ b/pkg/resources/schemas/template_test.go @@ -0,0 +1,491 @@ +// Package schemas handles streaming schema updates and changes. +package schemas_test + +import ( + "context" + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/steve/pkg/accesscontrol" + acfake "github.com/rancher/steve/pkg/accesscontrol/fake" + "github.com/rancher/steve/pkg/attributes" + "github.com/rancher/steve/pkg/resources/schemas" + schemafake "github.com/rancher/steve/pkg/schema/fake" + v1schema "github.com/rancher/wrangler/v3/pkg/schemas" + "github.com/stretchr/testify/assert" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/endpoints/request" +) + +var setupTimeout = time.Millisecond * 50 + +const resourceType = "schemas" + +func Test_WatchChangeDetection(t *testing.T) { + ctrl := gomock.NewController(t) + asl := acfake.NewMockAccessSetLookup(ctrl) + userInfo := &user.DefaultInfo{ + Name: "test", + UID: "test", + Groups: nil, + Extra: nil, + } + accessSet := &accesscontrol.AccessSet{} + // always return the same empty accessSet for the test user + asl.EXPECT().AccessFor(userInfo).Return(accessSet).AnyTimes() + + req := httptest.NewRequest("GET", "/", nil) + + type testValues struct { + expectedChanges []types.APIEvent + mockFactory *schemafake.MockFactory + eventsReady chan struct{} + } + tests := []struct { + name string + setup func(*gomock.Controller) testValues + }{ + { + name: "Schemas have no change", + setup: func(ctrl *gomock.Controller) testValues { + factory := schemafake.NewMockFactory(ctrl) + eventsReady := make(chan struct{}) + baseSchemas := types.EmptyAPISchemas() + updateSchemas := types.EmptyAPISchemas() + + testSchema := types.APISchema{ + Schema: &v1schema.Schema{ + ID: "pod", + PluralName: "pods", + CollectionMethods: []string{"GET"}, + ResourceMethods: []string{"GET"}, + }, + } + + // initial schemas + baseSchemas.AddSchema(testSchema) + factory.EXPECT().Schemas(userInfo).Return(baseSchemas, nil) + + updateSchemas.AddSchema(testSchema) + + // return updated schemas for the second request + factory.EXPECT().Schemas(userInfo).DoAndReturn(func(_ user.Info) (*types.APISchemas, error) { + // signal that initial Schemas were called + close(eventsReady) + return updateSchemas, nil + }) + + expectedEvents := []types.APIEvent{} + return testValues{expectedEvents, factory, eventsReady} + }, + }, + { + name: "New schema is added to schemas.", + setup: func(ctrl *gomock.Controller) testValues { + factory := schemafake.NewMockFactory(ctrl) + eventsReady := make(chan struct{}) + baseSchemas := types.EmptyAPISchemas() + updateSchemas := types.EmptyAPISchemas() + + testSchema := types.APISchema{ + Schema: &v1schema.Schema{ + ID: "pod", + PluralName: "pods", + CollectionMethods: []string{"GET"}, + ResourceMethods: []string{"GET"}, + }, + } + testSchemaNew := types.APISchema{ + Schema: &v1schema.Schema{ + ID: "secret", + PluralName: "secrets", + CollectionMethods: []string{"GET"}, + ResourceMethods: []string{"GET"}, + }, + } + baseSchemas.AddSchema(testSchema) + // initial schemas + factory.EXPECT().Schemas(userInfo).Return(baseSchemas, nil) + + updateSchemas.AddSchema(testSchema) + updateSchemas.AddSchema((testSchemaNew)) + + // return updated schemas for the second request + factory.EXPECT().Schemas(userInfo).DoAndReturn(func(_ user.Info) (*types.APISchemas, error) { + // signal that initial Schemas were called + close(eventsReady) + return updateSchemas, nil + }) + + expectedEvents := []types.APIEvent{ + { + Name: types.CreateAPIEvent, + ResourceType: "schema", + Object: types.APIObject{ + Type: resourceType, + ID: testSchemaNew.ID, + Object: &testSchemaNew, + }, + }, + } + return testValues{expectedEvents, factory, eventsReady} + }, + }, + { + name: "Schema is deleted from schemas.", + setup: func(ctrl *gomock.Controller) testValues { + factory := schemafake.NewMockFactory(ctrl) + eventsReady := make(chan struct{}) + baseSchemas := types.EmptyAPISchemas() + updateSchemas := types.EmptyAPISchemas() + + testSchema := types.APISchema{ + Schema: &v1schema.Schema{ + ID: "pod", + PluralName: "pods", + CollectionMethods: []string{"GET"}, + ResourceMethods: []string{"GET"}, + }, + } + testSchemaToDelete := types.APISchema{ + Schema: &v1schema.Schema{ + ID: "secret", + PluralName: "secrets", + CollectionMethods: []string{"GET"}, + ResourceMethods: []string{"GET"}, + }, + } + baseSchemas.AddSchema(testSchema) + baseSchemas.AddSchema(testSchemaToDelete) + // initial schemas + factory.EXPECT().Schemas(userInfo).Return(baseSchemas, nil) + + updateSchemas.AddSchema(testSchema) + + // return updated schemas for the second request + factory.EXPECT().Schemas(userInfo).DoAndReturn(func(_ user.Info) (*types.APISchemas, error) { + // signal that initial Schemas were called + close(eventsReady) + return updateSchemas, nil + }) + + expectedEvents := []types.APIEvent{ + { + Name: types.RemoveAPIEvent, + ResourceType: "schema", + Object: types.APIObject{ + Type: resourceType, + ID: testSchemaToDelete.ID, + Object: &testSchemaToDelete, + }, + }, + } + return testValues{expectedEvents, factory, eventsReady} + }, + }, + { + name: "Empty Schemas", + setup: func(ctrl *gomock.Controller) testValues { + factory := schemafake.NewMockFactory(ctrl) + eventsReady := make(chan struct{}) + + // initial schemas + factory.EXPECT().Schemas(userInfo).Return(types.EmptyAPISchemas(), nil) + + // return updated schemas for the second request + factory.EXPECT().Schemas(userInfo).DoAndReturn(func(_ user.Info) (*types.APISchemas, error) { + // signal that initial Schemas were called + close(eventsReady) + return types.EmptyAPISchemas(), nil + }) + + return testValues{nil, factory, eventsReady} + }, + }, + { + name: "Schema kind attribute is updated", + setup: func(ctrl *gomock.Controller) testValues { + factory := schemafake.NewMockFactory(ctrl) + eventsReady := make(chan struct{}) + baseSchemas := types.EmptyAPISchemas() + updateSchemas := types.EmptyAPISchemas() + + testSchema := types.APISchema{ + Schema: &v1schema.Schema{ + ID: "pod", + PluralName: "pods", + CollectionMethods: []string{"GET"}, + ResourceMethods: []string{"GET"}, + }, + } + baseSchemas.AddSchema(testSchema) + // initial schemas + factory.EXPECT().Schemas(userInfo).Return(baseSchemas, nil) + + // add kind attribute + attributes.SetKind(&testSchema, "newKind") + updateSchemas.AddSchema(testSchema) + + // return updated schemas for the second request + factory.EXPECT().Schemas(userInfo).DoAndReturn(func(_ user.Info) (*types.APISchemas, error) { + // signal that initial Schemas were called + close(eventsReady) + return updateSchemas, nil + }) + + expectedEvents := []types.APIEvent{ + { + Name: types.ChangeAPIEvent, + ResourceType: "schema", + Object: types.APIObject{ + Type: resourceType, + ID: testSchema.ID, + Object: &testSchema, + }, + }, + } + return testValues{expectedEvents, factory, eventsReady} + }, + }, + { + name: "Schema access attribute is updated", + setup: func(ctrl *gomock.Controller) testValues { + factory := schemafake.NewMockFactory(ctrl) + eventsReady := make(chan struct{}) + baseSchemas := types.EmptyAPISchemas() + updateSchemas := types.EmptyAPISchemas() + + testSchema := types.APISchema{ + Schema: &v1schema.Schema{ + ID: "pod", + PluralName: "pods", + CollectionMethods: []string{"GET"}, + ResourceMethods: []string{"GET"}, + }, + } + baseSchemas.AddSchema(testSchema) + // initial schemas + factory.EXPECT().Schemas(userInfo).Return(baseSchemas, nil) + + // add access attribute + attributes.SetAccess(&testSchema, map[string]string{"List": "*"}) + updateSchemas.AddSchema(testSchema) + + // return updated schemas for the second request + factory.EXPECT().Schemas(userInfo).DoAndReturn(func(_ user.Info) (*types.APISchemas, error) { + // signal that schemas were requested + close(eventsReady) + return updateSchemas, nil + }) + + expectedEvents := []types.APIEvent{} + return testValues{expectedEvents, factory, eventsReady} + }, + }, + } + + for i := range tests { + test := tests[i] + t.Run(test.name, func(t *testing.T) { + t.Parallel() + // create new context for the test user + testCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + apiOp := &types.APIRequest{ + Request: req.WithContext(request.WithUser(testCtx, userInfo)), + } + + // create test factory + ctrl := gomock.NewController(t) + values := test.setup(ctrl) + + // store onChange cb use to trigger the notifier that will be set in schemas.SetupWatcher(..) + var onChangeCB func() + values.mockFactory.EXPECT().OnChange(gomock.AssignableToTypeOf(testCtx), gomock.AssignableToTypeOf(onChangeCB)). + Do(func(_ context.Context, cb func()) { + onChangeCB = cb + }) + + baseSchemas := types.EmptyAPISchemas() + + // create a new store and add it to baseSchemas + schemas.SetupWatcher(testCtx, baseSchemas, asl, values.mockFactory) + schema := baseSchemas.LookupSchema(resourceType) + + // Start watching + resultChan, err := schema.Store.Watch(apiOp, nil, types.WatchRequest{}) + assert.NoError(t, err, "Unexpected error starting Watch") + + // wait for the store's go routines to start watching for onChange events + time.Sleep(setupTimeout) + + // trigger watch notification that fetches new schemas + onChangeCB() + + select { + case <-values.eventsReady: + // New schema was requested now we sleep to give time for watcher to send events + time.Sleep(setupTimeout) + case <-time.After(setupTimeout): + // When we continue here then the test will fail due to missing mock calls not being called. + } + + // verify correct results are sent + hasExpectedResults(t, values.expectedChanges, resultChan, setupTimeout) + }) + } +} + +func Test_AccessSetAndChangeSignal(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + asl := acfake.NewMockAccessSetLookup(ctrl) + userInfo := &user.DefaultInfo{ + Name: "test", + UID: "test", + Groups: nil, + Extra: nil, + } + accessSet := &accesscontrol.AccessSet{} + changedSet := &accesscontrol.AccessSet{ID: "1"} + + // return access set with ID "" the first time then "1" for subsequent request + gomock.InOrder( + asl.EXPECT().AccessFor(userInfo).Return(accessSet), + asl.EXPECT().AccessFor(userInfo).Return(changedSet).AnyTimes(), + ) + + req := httptest.NewRequest("GET", "/", nil) + + factory := schemafake.NewMockFactory(ctrl) + baseSchemas := types.EmptyAPISchemas() + onChangeUpdateSchemas := types.EmptyAPISchemas() + userAccessUpdateSchemas := types.EmptyAPISchemas() + + testSchema := types.APISchema{ + Schema: &v1schema.Schema{ + ID: "pod", + PluralName: "pods", + CollectionMethods: []string{"GET"}, + ResourceMethods: []string{"GET"}, + }, + } + + // initial schemas + baseSchemas.AddSchema(testSchema) + factory.EXPECT().Schemas(userInfo).Return(baseSchemas, nil) + + testSchemaNew := types.APISchema{ + Schema: &v1schema.Schema{ + ID: "secret", + PluralName: "secrets", + CollectionMethods: []string{"GET"}, + ResourceMethods: []string{"GET"}, + }, + } + onChangeUpdateSchemas.AddSchema(testSchema) + onChangeUpdateSchemas.AddSchema((testSchemaNew)) + + // return updated schemas with new schemas added + factory.EXPECT().Schemas(userInfo).Return(onChangeUpdateSchemas, nil) + + userAccessUpdateSchemas.AddSchema(testSchema) + + // return updated schemas with new schemas removed + factory.EXPECT().Schemas(userInfo).Return(userAccessUpdateSchemas, nil) + + expectedEvents := []types.APIEvent{ + { + Name: types.CreateAPIEvent, + ResourceType: "schema", + Object: types.APIObject{ + Type: resourceType, + ID: testSchemaNew.ID, + Object: &testSchemaNew, + }, + }, + { + Name: types.RemoveAPIEvent, + ResourceType: "schema", + Object: types.APIObject{ + Type: resourceType, + ID: testSchemaNew.ID, + Object: &testSchemaNew, + }, + }, + } + // create new context for the test user + testCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + apiOp := &types.APIRequest{ + Request: req.WithContext(request.WithUser(testCtx, userInfo)), + } + + // store onChange cb use to trigger the notifier that will be set in schemas.SetupWatcher(..) + var onChangeCB func() + factory.EXPECT().OnChange(gomock.AssignableToTypeOf(testCtx), gomock.AssignableToTypeOf(onChangeCB)). + Do(func(_ context.Context, cb func()) { + onChangeCB = cb + }) + + watcherSchema := types.EmptyAPISchemas() + + // create a new store and add it to watcherSchema + schemas.SetupWatcher(testCtx, watcherSchema, asl, factory) + schema := watcherSchema.LookupSchema(resourceType) + + // Start watching + resultChan, err := schema.Store.Watch(apiOp, nil, types.WatchRequest{}) + assert.NoError(t, err, "Unexpected error starting Watch") + + // wait for the store's go routines to start watching for onChange events + time.Sleep(setupTimeout) + + // trigger watch notification that fetches new schemas + onChangeCB() + + // wait for user access set to be checked (2 seconds) + time.Sleep(time.Millisecond * 2100) + + // verify correct results are sent + hasExpectedResults(t, expectedEvents, resultChan, setupTimeout) + +} + +// hasExpectedResults verifies the list of expected apiEvents are all received from the provided channel. +func hasExpectedResults(t *testing.T, expectedEvents []types.APIEvent, resultChan chan types.APIEvent, timeout time.Duration) { + t.Helper() + numEventsSent := 0 + for { + select { + case event, ok := <-resultChan: + if !ok { + if numEventsSent == len(expectedEvents) { + // we got everything we expect + return + } + assert.Fail(t, "result channel unexpectedly closed") + } + if numEventsSent >= len(expectedEvents) { + assert.Failf(t, "too many events", "received unexpected events on channel %+v", event) + return + } + eventJSON, err := json.Marshal(event) + assert.NoError(t, err, "failed to marshal new event") + expectedJSON, err := json.Marshal(event) + assert.NoError(t, err, "failed to marshal expected event") + assert.JSONEq(t, string(expectedJSON), string(eventJSON), "incorrect event received") + + case <-time.After(timeout): + if numEventsSent != len(expectedEvents) { + assert.Fail(t, "timeout waiting for results") + } + return + } + numEventsSent++ + } +} diff --git a/pkg/schema/collection.go b/pkg/schema/collection.go index 916e78f1..f1fbcedc 100644 --- a/pkg/schema/collection.go +++ b/pkg/schema/collection.go @@ -6,26 +6,17 @@ import ( "strings" "sync" - "github.com/rancher/apiserver/pkg/server" + apiserver "github.com/rancher/apiserver/pkg/server" "github.com/rancher/apiserver/pkg/types" "github.com/rancher/steve/pkg/accesscontrol" "github.com/rancher/steve/pkg/attributes" - "github.com/rancher/wrangler/pkg/name" + "github.com/rancher/wrangler/v3/pkg/name" "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/cache" - "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/endpoints/request" ) -type Factory interface { - Schemas(user user.Info) (*types.APISchemas, error) - ByGVR(gvr schema.GroupVersionResource) string - ByGVK(gvr schema.GroupVersionKind) string - OnChange(ctx context.Context, cb func()) - AddTemplate(template ...Template) -} - type Collection struct { toSync int32 baseSchema *types.APISchemas @@ -36,6 +27,7 @@ type Collection struct { byGVR map[schema.GroupVersionResource]string byGVK map[schema.GroupVersionKind]string cache *cache.LRUExpireCache + userCache *cache.LRUExpireCache lock sync.RWMutex ctx context.Context @@ -54,7 +46,7 @@ type Template struct { StoreFactory func(types.Store) types.Store } -func WrapServer(factory Factory, server *server.Server) http.Handler { +func WrapServer(factory Factory, server *apiserver.Server) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { user, ok := request.UserFrom(req.Context()) if !ok { @@ -84,6 +76,7 @@ func NewCollection(ctx context.Context, baseSchema *types.APISchemas, access acc byGVR: map[schema.GroupVersionResource]string{}, byGVK: map[schema.GroupVersionKind]string{}, cache: cache.NewLRUExpireCache(1000), + userCache: cache.NewLRUExpireCache(1000), notifiers: map[int]func(){}, ctx: ctx, as: access, diff --git a/pkg/schema/converter/crd.go b/pkg/schema/converter/crd.go index 1e021452..fb6bb1f7 100644 --- a/pkg/schema/converter/crd.go +++ b/pkg/schema/converter/crd.go @@ -4,9 +4,9 @@ import ( "github.com/rancher/apiserver/pkg/types" "github.com/rancher/steve/pkg/attributes" "github.com/rancher/steve/pkg/schema/table" - apiextv1 "github.com/rancher/wrangler/pkg/generated/controllers/apiextensions.k8s.io/v1" - "github.com/rancher/wrangler/pkg/schemas" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextv1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/apiextensions.k8s.io/v1" + "github.com/rancher/wrangler/v3/pkg/schemas" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" ) @@ -27,7 +27,9 @@ var ( } ) -func AddCustomResources(crd apiextv1.CustomResourceDefinitionClient, schemas map[string]*types.APISchema) error { +// addCustomResources uses the openAPISchema defined on CRDs to provide field definitions to previously discovered schemas. +// Note that this function does not create new schemas - it only adds details to resources already present in the schemas map. +func addCustomResources(crd apiextv1.CustomResourceDefinitionClient, schemas map[string]*types.APISchema) error { crds, err := crd.List(metav1.ListOptions{}) if err != nil { return nil @@ -41,14 +43,14 @@ func AddCustomResources(crd apiextv1.CustomResourceDefinitionClient, schemas map group, kind := crd.Spec.Group, crd.Status.AcceptedNames.Kind for _, version := range crd.Spec.Versions { - forVersion(&crd, group, kind, version, schemas) + forVersion(group, kind, version, schemas) } } return nil } -func forVersion(crd *v1.CustomResourceDefinition, group, kind string, version v1.CustomResourceDefinitionVersion, schemasMap map[string]*types.APISchema) { +func forVersion(group, kind string, version v1.CustomResourceDefinitionVersion, schemasMap map[string]*types.APISchema) { var versionColumns []table.Column for _, col := range version.AdditionalPrinterColumns { versionColumns = append(versionColumns, table.Column{ @@ -73,18 +75,6 @@ func forVersion(crd *v1.CustomResourceDefinition, group, kind string, version v1 attributes.SetColumns(schema, versionColumns) } if version.Schema != nil && version.Schema.OpenAPIV3Schema != nil { - if fieldsSchema := modelV3ToSchema(id, crd.Spec.Versions[0].Schema.OpenAPIV3Schema, schemasMap); fieldsSchema != nil { - for k, v := range staticFields { - fieldsSchema.ResourceFields[k] = v - } - for k, v := range fieldsSchema.ResourceFields { - if schema.ResourceFields == nil { - schema.ResourceFields = map[string]schemas.Field{} - } - if _, ok := schema.ResourceFields[k]; !ok { - schema.ResourceFields[k] = v - } - } - } + schema.Description = version.Schema.OpenAPIV3Schema.Description } } diff --git a/pkg/schema/converter/crd_test.go b/pkg/schema/converter/crd_test.go new file mode 100644 index 00000000..4ddf1416 --- /dev/null +++ b/pkg/schema/converter/crd_test.go @@ -0,0 +1,307 @@ +package converter + +import ( + "fmt" + "testing" + + "github.com/golang/mock/gomock" + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/steve/pkg/schema/table" + "github.com/rancher/wrangler/v3/pkg/generic/fake" + wranglerSchema "github.com/rancher/wrangler/v3/pkg/schemas" + "github.com/stretchr/testify/assert" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +func TestAddCustomResources(t *testing.T) { + tests := []struct { + name string + crds []v1.CustomResourceDefinition + preFilledSchemas []string // crds to pre-create schemas for + crdError error + wantError bool + desiredSchema map[string]*types.APISchema + }{ + { + name: "one crd - all different field types", + crds: []v1.CustomResourceDefinition{ + { + Status: v1.CustomResourceDefinitionStatus{ + AcceptedNames: v1.CustomResourceDefinitionNames{ + Plural: "testResources", + Singular: "testResource", + Kind: "TestResource", + }, + }, + Spec: v1.CustomResourceDefinitionSpec{ + Group: "testGroup", + Versions: []v1.CustomResourceDefinitionVersion{ + { + Name: "v1", + AdditionalPrinterColumns: []v1.CustomResourceColumnDefinition{ + { + Name: "TestColumn", + JSONPath: "TestPath", + Type: "TestType", + Format: "TestFormat", + }, + }, + Schema: &v1.CustomResourceValidation{ + OpenAPIV3Schema: &v1.JSONSchemaProps{ + Description: "Test Resource for unit tests", + Required: []string{"required"}, + Properties: map[string]v1.JSONSchemaProps{ + "required": { + Description: "Required Property", + Type: "string", + }, + "numberField": { + Description: "NumberField - Not Required Property", + Type: "number", + }, + "stringField": { + Description: "StringField - Not Required Property", + Type: "string", + }, + "nullArrayField": { + Description: "ArrayField with no type - Not Required Property", + Type: "array", + }, + "objectArrayField": { + Description: "ArrayField with an object type - Not Required Property", + Type: "array", + Items: &v1.JSONSchemaPropsOrArray{ + Schema: &v1.JSONSchemaProps{ + Type: "object", + }, + }, + }, + "objectArrayJSONField": { + Description: "ArrayField with an object type defined in JSONSchemas - Not Required Property", + Type: "array", + Items: &v1.JSONSchemaPropsOrArray{ + JSONSchemas: []v1.JSONSchemaProps{ + { + Type: "object", + }, + }, + }, + }, + "stringArrayField": { + Description: "ArrayField with a string type - Not Required Property", + Type: "array", + Items: &v1.JSONSchemaPropsOrArray{ + Schema: &v1.JSONSchemaProps{ + Type: "string", + }, + }, + }, + "stringArrayJSONField": { + Description: "ArrayField with a string type defined in JSONSchemas - Not Required Property", + Type: "array", + Items: &v1.JSONSchemaPropsOrArray{ + JSONSchemas: []v1.JSONSchemaProps{ + { + Type: "string", + }, + }, + }, + }, + "stringArrayBothField": { + Description: "ArrayField with a string type defined in both Schema and JSONSchemas - Not Required Property", + Type: "array", + Items: &v1.JSONSchemaPropsOrArray{ + Schema: &v1.JSONSchemaProps{ + Type: "string", + }, + JSONSchemas: []v1.JSONSchemaProps{ + { + Type: "object", + }, + }, + }, + }, + "nullObjectField": { + Description: "ObjectField with no type - Not Required Property", + Type: "object", + }, + "additionalPropertiesObjectField": { + Description: "ObjectField with a type in additionalProperties - Not Required Property", + Type: "object", + AdditionalProperties: &v1.JSONSchemaPropsOrBool{ + Schema: &v1.JSONSchemaProps{ + Type: "string", + }, + }, + }, + "nestedObjectField": { + Description: "ObjectField with an object type in additionalProperties - Not Required Property", + Type: "object", + AdditionalProperties: &v1.JSONSchemaPropsOrBool{ + Schema: &v1.JSONSchemaProps{ + Type: "object", + }, + }, + }, + "actions": { + Description: "Reserved field - Not Required Property", + Type: "string", + }, + }, + }, + }, + }, + }, + Names: v1.CustomResourceDefinitionNames{ + Plural: "testResources", + Singular: "testResource", + Kind: "TestResource", + }, + }, + }, + }, + preFilledSchemas: []string{"testgroup.v1.testresource"}, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + Attributes: map[string]interface{}{ + "columns": []table.Column{ + { + Name: "TestColumn", + Field: "TestPath", + Type: "TestType", + Format: "TestFormat", + }, + }, + }, + Description: "Test Resource for unit tests", + }, + }, + }, + }, + { + name: "crd list error - early break, no error", + crds: []v1.CustomResourceDefinition{}, + crdError: fmt.Errorf("unable to list crds"), + wantError: false, + desiredSchema: map[string]*types.APISchema{}, + }, + { + name: "skip resource - no plural name", + crds: []v1.CustomResourceDefinition{ + { + Status: v1.CustomResourceDefinitionStatus{ + AcceptedNames: v1.CustomResourceDefinitionNames{ + Singular: "testResource", + Kind: "TestResource", + }, + }, + Spec: v1.CustomResourceDefinitionSpec{ + Group: "testGroup", + Versions: []v1.CustomResourceDefinitionVersion{ + { + Schema: &v1.CustomResourceValidation{ + OpenAPIV3Schema: &v1.JSONSchemaProps{ + Description: "Test Resource for unit tests", + Required: []string{"required"}, + Properties: map[string]v1.JSONSchemaProps{ + "required": { + Description: "Required Property", + Type: "string", + }, + }, + }, + }, + }, + }, + Names: v1.CustomResourceDefinitionNames{ + Singular: "testResource", + Kind: "TestResource", + }, + }, + }, + }, + preFilledSchemas: []string{"testgroup.v1.testresource"}, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + }, + }, + }, + }, + { + name: "skip resource - no pre-defined schema", + crds: []v1.CustomResourceDefinition{ + { + Status: v1.CustomResourceDefinitionStatus{ + AcceptedNames: v1.CustomResourceDefinitionNames{ + Plural: "testResources", + Singular: "testResource", + Kind: "TestResource", + }, + }, + Spec: v1.CustomResourceDefinitionSpec{ + Group: "testGroup", + Versions: []v1.CustomResourceDefinitionVersion{ + { + Schema: &v1.CustomResourceValidation{ + OpenAPIV3Schema: &v1.JSONSchemaProps{ + Description: "Test Resource for unit tests", + Required: []string{"required"}, + Properties: map[string]v1.JSONSchemaProps{ + "required": { + Description: "Required Property", + Type: "string", + }, + }, + }, + }, + }, + }, + Names: v1.CustomResourceDefinitionNames{ + Plural: "testResources", + Singular: "testResource", + Kind: "TestResource", + }, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{}, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + fakeClient := fake.NewMockNonNamespacedClientInterface[*v1.CustomResourceDefinition, *v1.CustomResourceDefinitionList](ctrl) + var crds *v1.CustomResourceDefinitionList + if test.crds != nil { + crds = &v1.CustomResourceDefinitionList{ + Items: test.crds, + } + } + fakeClient.EXPECT().List(gomock.Any()).Return(crds, test.crdError) + schemas := map[string]*types.APISchema{} + for i := range test.preFilledSchemas { + schemas[test.preFilledSchemas[i]] = &types.APISchema{ + Schema: &wranglerSchema.Schema{ + ID: test.preFilledSchemas[i], + }, + } + } + err := addCustomResources(fakeClient, schemas) + if test.wantError { + assert.Error(t, err, "expected an error but there was no error") + } else { + assert.NoError(t, err, "got an unexpected error") + } + assert.Equal(t, test.desiredSchema, schemas) + }) + } +} diff --git a/pkg/schema/converter/description.go b/pkg/schema/converter/description.go new file mode 100644 index 00000000..11d784b3 --- /dev/null +++ b/pkg/schema/converter/description.go @@ -0,0 +1,45 @@ +package converter + +import ( + "github.com/rancher/apiserver/pkg/types" + "github.com/sirupsen/logrus" + "k8s.io/client-go/discovery" + "k8s.io/kube-openapi/pkg/util/proto" +) + +// addDescription adds a description to all schemas in schemas using the openapi v2 definitions from k8s. +// Will not add new schemas, only mutate existing ones. Returns an error if the definitions could not be retrieved. +func addDescription(client discovery.DiscoveryInterface, schemas map[string]*types.APISchema) error { + openapi, err := client.OpenAPISchema() + if err != nil { + return err + } + + models, err := proto.NewOpenAPIData(openapi) + if err != nil { + return err + } + + for _, modelName := range models.ListModels() { + model := models.LookupModel(modelName) + if k, ok := model.(*proto.Kind); ok { + gvk := GetGVKForKind(k) + if gvk == nil { + // kind was not for top level gvk, we can skip this resource + logrus.Tracef("when adding schema descriptions, will not add description for kind %s, which is not a top level resource", k.Path.String()) + continue + } + schemaID := GVKToVersionedSchemaID(*gvk) + schema, ok := schemas[schemaID] + // some kinds have a gvk but don't correspond to a schema (like a podList). We can + // skip these resources as well + if !ok { + logrus.Tracef("when adding schema descriptions, will not add description for ID %s, which is not in schemas", schemaID) + continue + } + schema.Description = k.GetDescription() + } + } + + return nil +} diff --git a/pkg/schema/converter/description_test.go b/pkg/schema/converter/description_test.go new file mode 100644 index 00000000..6b258149 --- /dev/null +++ b/pkg/schema/converter/description_test.go @@ -0,0 +1,151 @@ +package converter + +import ( + "fmt" + "testing" + + openapiv2 "github.com/google/gnostic-models/openapiv2" + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/wrangler/v3/pkg/schemas" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestAddDescription(t *testing.T) { + gvkExtensionMap := map[any]any{ + gvkExtensionGroup: "management.cattle.io", + gvkExtensionVersion: "v3", + gvkExtensionKind: "GlobalRole", + } + gvkExtensionSlice := []any{gvkExtensionMap} + extensionSliceYaml, err := yaml.Marshal(gvkExtensionSlice) + require.NoError(t, err) + gvkSchema := openapiv2.NamedSchema{ + Name: "GlobalRoles", + Value: &openapiv2.Schema{ + Description: "GlobalRoles are Global permissions in Rancher", + Type: &openapiv2.TypeItem{ + Value: []string{"object"}, + }, + Properties: &openapiv2.Properties{ + AdditionalProperties: []*openapiv2.NamedSchema{}, + }, + VendorExtension: []*openapiv2.NamedAny{ + { + Name: gvkExtensionName, + Value: &openapiv2.Any{ + Yaml: string(extensionSliceYaml), + }, + }, + }, + }, + } + noGVKSchema := openapiv2.NamedSchema{ + Name: "GlobalRoleSpec", + Value: &openapiv2.Schema{ + Description: "The Spec of a GlobalRole", + Type: &openapiv2.TypeItem{ + Value: []string{"object"}, + }, + Properties: &openapiv2.Properties{ + AdditionalProperties: []*openapiv2.NamedSchema{}, + }, + }, + } + errorSchema := openapiv2.NamedSchema{ + Name: "InvalidResource", + Value: &openapiv2.Schema{ + Description: "Resource that is invalid due to multiple types", + Type: &openapiv2.TypeItem{ + Value: []string{"object", "map"}, + }, + }, + } + tests := []struct { + name string + documentSchemas []*openapiv2.NamedSchema + clientErr error + inputSchemas map[string]*types.APISchema + wantSchemas map[string]*types.APISchema + wantErr bool + }{ + { + name: "basic gvk schema", + documentSchemas: []*openapiv2.NamedSchema{&gvkSchema}, + inputSchemas: map[string]*types.APISchema{ + "management.cattle.io.v3.globalrole": { + Schema: &schemas.Schema{}, + }, + }, + wantSchemas: map[string]*types.APISchema{ + "management.cattle.io.v3.globalrole": { + Schema: &schemas.Schema{ + Description: gvkSchema.Value.Description, + }, + }, + }, + }, + { + name: "kind has a gvk, but no schema", + documentSchemas: []*openapiv2.NamedSchema{&gvkSchema}, + inputSchemas: map[string]*types.APISchema{ + "management.cattle.io.v3.otherschema": { + Schema: &schemas.Schema{}, + }, + }, + wantSchemas: map[string]*types.APISchema{ + "management.cattle.io.v3.otherschema": { + Schema: &schemas.Schema{}, + }, + }, + }, + { + name: "schema without gvk", + documentSchemas: []*openapiv2.NamedSchema{&noGVKSchema}, + inputSchemas: map[string]*types.APISchema{ + "management.cattle.io.v3.globalrole": { + Schema: &schemas.Schema{}, + }, + }, + wantSchemas: map[string]*types.APISchema{ + "management.cattle.io.v3.globalrole": { + Schema: &schemas.Schema{}, + }, + }, + }, + { + name: "discovery error", + clientErr: fmt.Errorf("server not available"), + wantErr: true, + }, + { + name: "invalid models", + documentSchemas: []*openapiv2.NamedSchema{&errorSchema}, + wantErr: true, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + document := openapiv2.Document{ + Definitions: &openapiv2.Definitions{ + AdditionalProperties: test.documentSchemas, + }, + } + fakeDiscovery := fakeDiscovery{ + Document: &document, + DocumentErr: test.clientErr, + } + gotErr := addDescription(&fakeDiscovery, test.inputSchemas) + if test.wantErr { + require.Error(t, gotErr) + } else { + require.NoError(t, gotErr) + } + // inputSchemas are modified in place + require.Equal(t, test.wantSchemas, test.inputSchemas) + }) + } +} diff --git a/pkg/schema/converter/discovery.go b/pkg/schema/converter/discovery.go index 058a0d49..f9fb1f5d 100644 --- a/pkg/schema/converter/discovery.go +++ b/pkg/schema/converter/discovery.go @@ -5,8 +5,8 @@ import ( "github.com/rancher/apiserver/pkg/types" "github.com/rancher/steve/pkg/attributes" - "github.com/rancher/wrangler/pkg/merr" - "github.com/rancher/wrangler/pkg/schemas" + "github.com/rancher/wrangler/v3/pkg/merr" + "github.com/rancher/wrangler/v3/pkg/schemas" "github.com/sirupsen/logrus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -22,7 +22,9 @@ var ( } ) -func AddDiscovery(client discovery.DiscoveryInterface, schemasMap map[string]*types.APISchema) error { +// addDiscovery uses a k8s discovery client to create very basic schemas for all registered groups/resources. Other +// functions, such as addCustomResources are used to add more details to these schemas later on. +func addDiscovery(client discovery.DiscoveryInterface, schemasMap map[string]*types.APISchema) error { groups, resourceLists, err := client.ServerGroupsAndResources() if gd, ok := err.(*discovery.ErrGroupDiscoveryFailed); ok { logrus.Errorf("Failed to read API for groups %v", gd.Groups) diff --git a/pkg/schema/converter/discovery_test.go b/pkg/schema/converter/discovery_test.go new file mode 100644 index 00000000..3bd7d2e9 --- /dev/null +++ b/pkg/schema/converter/discovery_test.go @@ -0,0 +1,419 @@ +package converter + +import ( + "fmt" + "testing" + + openapiv2 "github.com/google/gnostic-models/openapiv2" + "github.com/rancher/apiserver/pkg/types" + wranglerSchema "github.com/rancher/wrangler/v3/pkg/schemas" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/version" + "k8s.io/client-go/discovery" + "k8s.io/client-go/openapi" + restclient "k8s.io/client-go/rest" +) + +func TestAddDiscovery(t *testing.T) { + tests := []struct { + name string + discoveryErr error + groups []schema.GroupVersion + groupVersionOverride bool + resources map[schema.GroupVersion][]metav1.APIResource + wantError bool + desiredSchema map[string]*types.APISchema + }{ + { + name: "basic test case, one schema", + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + PluralName: "TestGroup.v1.testResources", + Attributes: map[string]interface{}{ + "group": "TestGroup", + "version": "v1", + "kind": "TestResource", + "resource": "testResources", + "verbs": []string{"get"}, + "namespaced": true, + }, + }, + }, + }, + }, + { + name: "discovery error but still got some information", + discoveryErr: &discovery.ErrGroupDiscoveryFailed{Groups: map[schema.GroupVersion]error{ + schema.GroupVersion{ + Group: "NotFound", + Version: "v1", + }: fmt.Errorf("group Not found"), + }, + }, + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + PluralName: "TestGroup.v1.testResources", + Attributes: map[string]interface{}{ + "group": "TestGroup", + "version": "v1", + "kind": "TestResource", + "resource": "testResources", + "verbs": []string{"get"}, + "namespaced": true, + }, + }, + }, + }, + }, + { + name: "discovery error, not partial", + discoveryErr: fmt.Errorf("cluster unavailable"), + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + wantError: true, + desiredSchema: map[string]*types.APISchema{}, + }, + { + name: "bad group version", + groups: []schema.GroupVersion{{Group: "Invalid/Group", Version: "v2"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "Invalid/Group", Version: "v2"}: { + { + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + wantError: true, + desiredSchema: map[string]*types.APISchema{ + "core..testresource": { + Schema: &wranglerSchema.Schema{ + ID: "core..testresource", + PluralName: "core..testResources", + Attributes: map[string]interface{}{ + "group": "", + "version": "", + "kind": "TestResource", + "resource": "testResources", + "verbs": []string{"get"}, + "namespaced": true, + }, + }, + }, + }, + }, + { + name: "override groups and versions", + groups: []schema.GroupVersion{{Group: "autoscaling", Version: "v1"}, {Group: "extensions", Version: "v1"}}, + groupVersionOverride: true, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "autoscaling", Version: "v1"}: { + { + Name: "testAutoscalings", + SingularName: "testAutoscaling", + Kind: "TestAutoscaling", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + {Group: "extensions", Version: "v1"}: { + { + Name: "testExtensions", + SingularName: "testExtension", + Kind: "TestExtension", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "autoscaling.v1.testautoscaling": { + Schema: &wranglerSchema.Schema{ + ID: "autoscaling.v1.testautoscaling", + PluralName: "autoscaling.v1.testAutoscalings", + Attributes: map[string]interface{}{ + "group": "autoscaling", + "version": "v1", + "kind": "TestAutoscaling", + "resource": "testAutoscalings", + "verbs": []string{"get"}, + "namespaced": true, + "preferredVersion": "v2beta2", + }, + }, + }, + "extensions.v1.testextension": { + Schema: &wranglerSchema.Schema{ + ID: "extensions.v1.testextension", + PluralName: "extensions.v1.testExtensions", + Attributes: map[string]interface{}{ + "group": "extensions", + "version": "v1", + "kind": "TestExtension", + "resource": "testExtensions", + "verbs": []string{"get"}, + "namespaced": true, + "preferredGroup": "apps", + }, + }, + }, + }, + }, + { + name: "eligible for override, but override version not found", + groups: []schema.GroupVersion{{Group: "autoscaling", Version: "v1"}}, + groupVersionOverride: false, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "autoscaling", Version: "v1"}: { + { + Name: "testAutoscalings", + SingularName: "testAutoscaling", + Kind: "TestAutoscaling", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "autoscaling.v1.testautoscaling": { + Schema: &wranglerSchema.Schema{ + ID: "autoscaling.v1.testautoscaling", + PluralName: "autoscaling.v1.testAutoscalings", + Attributes: map[string]interface{}{ + "group": "autoscaling", + "version": "v1", + "kind": "TestAutoscaling", + "resource": "testAutoscalings", + "verbs": []string{"get"}, + "namespaced": true, + }, + }, + }, + }, + }, + { + name: "skip resource with / silently", + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + Name: "test/Resources", + SingularName: "test/Resource", + Kind: "Test/Resource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + { + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: false, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + PluralName: "TestGroup.v1.testResources", + Attributes: map[string]interface{}{ + "group": "TestGroup", + "version": "v1", + "kind": "TestResource", + "resource": "testResources", + "verbs": []string{"get"}, + "namespaced": false, + }, + }, + }, + }, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + testDiscovery := fakeDiscovery{} + for _, gvr := range test.groups { + gvr := gvr + testDiscovery.AddGroup(gvr.Group, gvr.Version, test.groupVersionOverride) + } + for gvr, resourceSlice := range test.resources { + for _, resource := range resourceSlice { + resource := resource + testDiscovery.AddResource(gvr.Group, gvr.Version, resource) + } + } + testDiscovery.GroupResourcesErr = test.discoveryErr + schemas := map[string]*types.APISchema{} + err := addDiscovery(&testDiscovery, schemas) + if test.wantError { + assert.Error(t, err, "expected an error but did not get one") + } else { + assert.NoError(t, err, "got an error but did not expect one") + } + assert.Equal(t, test.desiredSchema, schemas, "schemas were not as expected") + }) + } +} + +type fakeDiscovery struct { + Groups []*metav1.APIGroup + Resources []*metav1.APIResourceList + Document *openapiv2.Document + GroupResourcesErr error + DocumentErr error +} + +// ServerGroupsAndResources is the only method we actually need for the test - just returns what is on the struct +func (f *fakeDiscovery) ServerGroupsAndResources() ([]*metav1.APIGroup, []*metav1.APIResourceList, error) { + return f.Groups, f.Resources, f.GroupResourcesErr +} + +func (f *fakeDiscovery) AddGroup(groupName string, preferredVersion string, includeOverrideVersion bool) { + if f.Groups == nil { + f.Groups = []*metav1.APIGroup{} + } + groupVersion := fmt.Sprintf("%s/%s", groupName, preferredVersion) + found := -1 + for i := range f.Groups { + if f.Groups[i].Name == groupName { + found = i + } + } + group := metav1.APIGroup{ + Name: groupName, + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: groupVersion, + Version: preferredVersion, + }, + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: groupVersion, + Version: preferredVersion, + }, + }, + } + + // if we should include override versions in list of versions, figure out if we have an override and add it + if includeOverrideVersion { + if override, ok := preferredVersionOverride[groupVersion]; ok { + group.Versions = append(group.Versions, metav1.GroupVersionForDiscovery{ + GroupVersion: fmt.Sprintf("%s/%s", groupName, override), + Version: override, + }) + } + } + if found >= 0 { + f.Groups[found] = &group + } else { + f.Groups = append(f.Groups, &group) + } +} + +func (f *fakeDiscovery) AddResource(group, version string, resource metav1.APIResource) { + if f.Resources == nil { + f.Resources = []*metav1.APIResourceList{} + } + groupVersion := fmt.Sprintf("%s/%s", group, version) + found := -1 + // first, find the APIResourceList for our group + for i := range f.Resources { + if f.Resources[i].GroupVersion == groupVersion { + found = i + } + } + + if found >= 0 { + currentResourceList := f.Resources[found] + resourceFound := -1 + // next, find the APIResource for our resource + for i := range currentResourceList.APIResources { + if currentResourceList.APIResources[i].Name == resource.Name { + resourceFound = i + } + } + if resourceFound >= 0 { + currentResourceList.APIResources[resourceFound] = resource + } else { + currentResourceList.APIResources = append(currentResourceList.APIResources, resource) + } + f.Resources[found] = currentResourceList + } else { + currentResourceList := &metav1.APIResourceList{ + GroupVersion: groupVersion, + APIResources: []metav1.APIResource{resource}, + } + f.Resources = append(f.Resources, currentResourceList) + } +} + +// The rest of these methods are just here to conform to discovery.DiscoveryInterface +func (f *fakeDiscovery) RESTClient() restclient.Interface { return nil } +func (f *fakeDiscovery) ServerGroups() (*metav1.APIGroupList, error) { return nil, nil } +func (f *fakeDiscovery) ServerResourcesForGroupVersion(groupVersion string) (*metav1.APIResourceList, error) { + return nil, nil +} +func (f *fakeDiscovery) ServerPreferredResources() ([]*metav1.APIResourceList, error) { + return nil, nil +} +func (f *fakeDiscovery) ServerPreferredNamespacedResources() ([]*metav1.APIResourceList, error) { + return nil, nil +} +func (f *fakeDiscovery) ServerVersion() (*version.Info, error) { return nil, nil } +func (f *fakeDiscovery) OpenAPISchema() (*openapiv2.Document, error) { + return f.Document, f.DocumentErr +} +func (f *fakeDiscovery) OpenAPIV3() openapi.Client { return nil } +func (f *fakeDiscovery) WithLegacy() discovery.DiscoveryInterface { return f } diff --git a/pkg/schema/converter/k8stonorman.go b/pkg/schema/converter/k8stonorman.go index cba8e106..c9d19096 100644 --- a/pkg/schema/converter/k8stonorman.go +++ b/pkg/schema/converter/k8stonorman.go @@ -1,3 +1,5 @@ +// Package converter is responsible for converting the types registered with a k8s server to schemas which can be used +// by the UI (and other consumers) to discover the resources available and the current user's permissions. package converter import ( @@ -5,9 +7,20 @@ import ( "strings" "github.com/rancher/apiserver/pkg/types" - "github.com/rancher/wrangler/pkg/generated/controllers/apiextensions.k8s.io/v1" + "github.com/rancher/norman/types/convert" + "github.com/rancher/steve/pkg/attributes" + "github.com/rancher/steve/pkg/resources/apigroups" + v1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/apiextensions.k8s.io/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/discovery" + "k8s.io/kube-openapi/pkg/util/proto" +) + +const ( + gvkExtensionName = "x-kubernetes-group-version-kind" + gvkExtensionGroup = "group" + gvkExtensionVersion = "version" + gvkExtensionKind = "kind" ) func GVKToVersionedSchemaID(gvk schema.GroupVersionKind) string { @@ -38,20 +51,59 @@ func GVRToPluralName(gvr schema.GroupVersionResource) string { return fmt.Sprintf("%s.%s", gvr.Group, gvr.Resource) } +// GetGVKForProto attempts to retrieve a GVK for a given OpenAPI V2 schema +// object. +// The GVK is defined in an extension. It is possible that the protoSchema does +// not have the GVK extension set - in that case, we return nil. +func GetGVKForProtoSchema(protoSchema proto.Schema) *schema.GroupVersionKind { + extensions, ok := protoSchema.GetExtensions()[gvkExtensionName].([]any) + if !ok { + return nil + } + for _, extension := range extensions { + if gvkExtension, ok := extension.(map[any]any); ok { + gvk := schema.GroupVersionKind{ + Group: convert.ToString(gvkExtension[gvkExtensionGroup]), + Version: convert.ToString(gvkExtension[gvkExtensionVersion]), + Kind: convert.ToString(gvkExtension[gvkExtensionKind]), + } + return &gvk + } + } + return nil +} + +// GetGVKForKind attempts to retrieve a GVK for a given Kind. Not all kind represent top level resources, +// so this function may return nil if the kind did not have a gvk extension +func GetGVKForKind(kind *proto.Kind) *schema.GroupVersionKind { + return GetGVKForProtoSchema(kind) +} + +// ToSchemas creates the schemas for a K8s server, using client to discover groups/resources, and crd to potentially +// add additional information about new fields/resources. Mostly ties together addDiscovery and addCustomResources. func ToSchemas(crd v1.CustomResourceDefinitionClient, client discovery.DiscoveryInterface) (map[string]*types.APISchema, error) { result := map[string]*types.APISchema{} - if err := AddOpenAPI(client, result); err != nil { + addTemplateBased(result) + + if err := addDiscovery(client, result); err != nil { return nil, err } - if err := AddDiscovery(client, result); err != nil { + if err := addCustomResources(crd, result); err != nil { return nil, err } - if err := AddCustomResources(crd, result); err != nil { + if err := addDescription(client, result); err != nil { return nil, err } return result, nil } + +// some schemas are not based on real resources but are filled-in by a template later on. This function adds the base +// schema so that these endpoints are still recognizable in the api +func addTemplateBased(schemas map[string]*types.APISchema) { + apiGroupGVK := attributes.GVK(&apigroups.BaseSchema) + schemas[GVKToVersionedSchemaID(apiGroupGVK)] = &apigroups.BaseSchema +} diff --git a/pkg/schema/converter/k8stonorman_test.go b/pkg/schema/converter/k8stonorman_test.go new file mode 100644 index 00000000..7d11de89 --- /dev/null +++ b/pkg/schema/converter/k8stonorman_test.go @@ -0,0 +1,606 @@ +package converter + +import ( + "fmt" + "testing" + + "github.com/golang/mock/gomock" + openapiv2 "github.com/google/gnostic-models/openapiv2" + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/steve/pkg/resources/apigroups" + "github.com/rancher/steve/pkg/schema/table" + "github.com/rancher/wrangler/v3/pkg/generic/fake" + wranglerSchema "github.com/rancher/wrangler/v3/pkg/schemas" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/kube-openapi/pkg/util/proto" +) + +func TestToSchemas(t *testing.T) { + createNamedSchema := func(name string, description string, gvk schema.GroupVersionKind) (*openapiv2.NamedSchema, error) { + gvkExtensionMap := map[any]any{ + gvkExtensionGroup: gvk.Group, + gvkExtensionVersion: gvk.Version, + gvkExtensionKind: gvk.Kind, + } + gvkExtensionSlice := []any{gvkExtensionMap} + extensionSliceYaml, err := yaml.Marshal(gvkExtensionSlice) + if err != nil { + return nil, fmt.Errorf("unable to create named schema for %s: %w", name, err) + } + return &openapiv2.NamedSchema{ + Name: name, + Value: &openapiv2.Schema{ + Description: description, + Type: &openapiv2.TypeItem{ + Value: []string{"object"}, + }, + Properties: &openapiv2.Properties{ + AdditionalProperties: []*openapiv2.NamedSchema{}, + }, + VendorExtension: []*openapiv2.NamedAny{ + { + Name: gvkExtensionName, + Value: &openapiv2.Any{ + Yaml: string(extensionSliceYaml), + }, + }, + }, + }, + }, nil + } + apiGroupDescription := "APIGroup contains the name, the supported versions, and the preferred version of a group" + gvkSchema, err := createNamedSchema("TestResources", "TestResources are test resource created for unit tests", schema.GroupVersionKind{ + Group: "TestGroup", + Version: "v1", + Kind: "TestResource", + }) + require.NoError(t, err) + apiGroupSchema, err := createNamedSchema("ApiGroups", apiGroupDescription, schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "APIGroup", + }) + require.NoError(t, err) + tests := []struct { + name string + groups []schema.GroupVersion + resources map[schema.GroupVersion][]metav1.APIResource + crds []v1.CustomResourceDefinition + document *openapiv2.Document + discoveryErr error + documentErr error + crdErr error + wantError bool + desiredSchema map[string]*types.APISchema + }{ + { + name: "crd listed in discovery, defined in crds", + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + crds: []v1.CustomResourceDefinition{ + { + Status: v1.CustomResourceDefinitionStatus{ + AcceptedNames: v1.CustomResourceDefinitionNames{ + Plural: "testResources", + Singular: "testResource", + Kind: "TestResource", + }, + }, + Spec: v1.CustomResourceDefinitionSpec{ + Group: "TestGroup", + Versions: []v1.CustomResourceDefinitionVersion{ + { + Name: "v1", + AdditionalPrinterColumns: []v1.CustomResourceColumnDefinition{ + { + Name: "TestColumn", + JSONPath: "TestPath", + Type: "TestType", + Format: "TestFormat", + }, + }, + Schema: &v1.CustomResourceValidation{ + OpenAPIV3Schema: &v1.JSONSchemaProps{ + Description: "Test Resource for unit tests", + Required: []string{"required"}, + Properties: map[string]v1.JSONSchemaProps{ + "required": { + Description: "Required Property", + Type: "string", + }, + "numberField": { + Description: "NumberField - Not Required Property", + Type: "number", + }, + "nullArrayField": { + Description: "ArrayField with no type - Not Required Property", + Type: "array", + }, + "nullObjectField": { + Description: "ObjectField with no type - Not Required Property", + Type: "object", + }, + "actions": { + Description: "Reserved field - Not Required Property", + Type: "string", + }, + }, + }, + }, + }, + }, + Names: v1.CustomResourceDefinitionNames{ + Plural: "testResources", + Singular: "testResource", + Kind: "TestResource", + }, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + PluralName: "TestGroup.v1.testResources", + Attributes: map[string]interface{}{ + "group": "TestGroup", + "version": "v1", + "kind": "TestResource", + "resource": "testResources", + "verbs": []string{"get"}, + "namespaced": true, + "columns": []table.Column{ + { + Name: "TestColumn", + Field: "TestPath", + Type: "TestType", + Format: "TestFormat", + }, + }, + }, + Description: "Test Resource for unit tests", + }, + }, + "core.v1.apigroup": &apigroups.BaseSchema, + }, + }, + { + name: "listed in discovery, not defined in crds", + crds: []v1.CustomResourceDefinition{}, + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + PluralName: "TestGroup.v1.testResources", + Attributes: map[string]interface{}{ + "group": "TestGroup", + "version": "v1", + "kind": "TestResource", + "resource": "testResources", + "verbs": []string{"get"}, + "namespaced": true, + }, + }, + }, + "core.v1.apigroup": &apigroups.BaseSchema, + }, + }, + { + name: "defined in crds, but not in discovery", + groups: []schema.GroupVersion{}, + resources: map[schema.GroupVersion][]metav1.APIResource{}, + crds: []v1.CustomResourceDefinition{ + { + Status: v1.CustomResourceDefinitionStatus{ + AcceptedNames: v1.CustomResourceDefinitionNames{ + Plural: "testResources", + Singular: "testResource", + Kind: "TestResource", + }, + }, + Spec: v1.CustomResourceDefinitionSpec{ + Group: "TestGroup", + Versions: []v1.CustomResourceDefinitionVersion{ + { + Name: "v1", + AdditionalPrinterColumns: []v1.CustomResourceColumnDefinition{ + { + Name: "TestColumn", + JSONPath: "TestPath", + Type: "TestType", + Format: "TestFormat", + }, + }, + Schema: &v1.CustomResourceValidation{ + OpenAPIV3Schema: &v1.JSONSchemaProps{ + Description: "Test Resource for unit tests", + Required: []string{"required"}, + Properties: map[string]v1.JSONSchemaProps{ + "required": { + Description: "Required Property", + Type: "string", + }, + "numberField": { + Description: "NumberField - Not Required Property", + Type: "number", + }, + "nullArrayField": { + Description: "ArrayField with no type - Not Required Property", + Type: "array", + }, + "nullObjectField": { + Description: "ObjectField with no type - Not Required Property", + Type: "object", + }, + "actions": { + Description: "Reserved field - Not Required Property", + Type: "string", + }, + }, + }, + }, + }, + }, + Names: v1.CustomResourceDefinitionNames{ + Plural: "testResources", + Singular: "testResource", + Kind: "TestResource", + }, + }, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "core.v1.apigroup": &apigroups.BaseSchema, + }, + }, + { + name: "discovery error", + groups: []schema.GroupVersion{}, + resources: map[schema.GroupVersion][]metav1.APIResource{}, + discoveryErr: fmt.Errorf("server is down, can't use discovery"), + crds: []v1.CustomResourceDefinition{}, + wantError: true, + desiredSchema: nil, + }, + { + name: "crd error", + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + crdErr: fmt.Errorf("unable to use crd client, insufficient permissions"), + crds: []v1.CustomResourceDefinition{}, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + PluralName: "TestGroup.v1.testResources", + Attributes: map[string]interface{}{ + "group": "TestGroup", + "version": "v1", + "kind": "TestResource", + "resource": "testResources", + "verbs": []string{"get"}, + "namespaced": true, + }, + }, + }, + "core.v1.apigroup": &apigroups.BaseSchema, + }, + }, + { + name: "adding descriptions", + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + crdErr: nil, + crds: []v1.CustomResourceDefinition{}, + document: &openapiv2.Document{ + Definitions: &openapiv2.Definitions{ + AdditionalProperties: []*openapiv2.NamedSchema{gvkSchema, apiGroupSchema}, + }, + }, + wantError: false, + desiredSchema: map[string]*types.APISchema{ + "testgroup.v1.testresource": { + Schema: &wranglerSchema.Schema{ + ID: "testgroup.v1.testresource", + Description: gvkSchema.Value.Description, + PluralName: "TestGroup.v1.testResources", + Attributes: map[string]interface{}{ + "group": "TestGroup", + "version": "v1", + "kind": "TestResource", + "resource": "testResources", + "verbs": []string{"get"}, + "namespaced": true, + }, + }, + }, + "core.v1.apigroup": { + Schema: &wranglerSchema.Schema{ + ID: "apigroup", + Attributes: map[string]interface{}{ + "group": "", + "kind": "APIGroup", + "version": "v1", + }, + Description: apiGroupDescription, + }, + }, + }, + }, + { + name: "descriptions error", + groups: []schema.GroupVersion{{Group: "TestGroup", Version: "v1"}}, + resources: map[schema.GroupVersion][]metav1.APIResource{ + {Group: "TestGroup", Version: "v1"}: { + { + + Name: "testResources", + SingularName: "testResource", + Kind: "TestResource", + Namespaced: true, + Verbs: metav1.Verbs{"get"}, + }, + }, + }, + crdErr: nil, + crds: []v1.CustomResourceDefinition{}, + document: nil, + documentErr: fmt.Errorf("can't get document"), + wantError: true, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + testDiscovery := fakeDiscovery{} + for _, gvr := range test.groups { + gvr := gvr + testDiscovery.AddGroup(gvr.Group, gvr.Version, false) + } + testDiscovery.Document = test.document + testDiscovery.DocumentErr = test.documentErr + for gvr, resourceSlice := range test.resources { + for _, resource := range resourceSlice { + resource := resource + testDiscovery.AddResource(gvr.Group, gvr.Version, resource) + } + } + testDiscovery.GroupResourcesErr = test.discoveryErr + var crds *v1.CustomResourceDefinitionList + if test.crds != nil { + crds = &v1.CustomResourceDefinitionList{ + Items: test.crds, + } + } + fakeClient := fake.NewMockNonNamespacedClientInterface[*v1.CustomResourceDefinition, *v1.CustomResourceDefinitionList](ctrl) + fakeClient.EXPECT().List(gomock.Any()).Return(crds, test.crdErr).AnyTimes() + + schemas, err := ToSchemas(fakeClient, &testDiscovery) + if test.wantError { + assert.Error(t, err, "wanted error but didn't get one") + } else { + assert.NoError(t, err, "got an error but did not want one") + } + assert.Equal(t, test.desiredSchema, schemas, "did not get the desired schemas") + }) + } + +} + +func TestGVKToVersionedSchemaID(t *testing.T) { + tests := []struct { + name string + gvk schema.GroupVersionKind + want string + }{ + { + name: "basic gvk", + gvk: schema.GroupVersionKind{ + Group: "TestGroup", + Version: "v1", + Kind: "TestKind", + }, + want: "testgroup.v1.testkind", + }, + { + name: "core resource", + gvk: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "TestKind", + }, + want: "core.v1.testkind", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, GVKToVersionedSchemaID(test.gvk)) + }) + } + +} + +func TestGVKToSchemaID(t *testing.T) { + tests := []struct { + name string + gvk schema.GroupVersionKind + want string + }{ + { + name: "basic gvk", + gvk: schema.GroupVersionKind{ + Group: "TestGroup", + Version: "v1", + Kind: "TestKind", + }, + want: "testgroup.testkind", + }, + { + name: "core resource", + gvk: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "TestKind", + }, + want: "testkind", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, GVKToSchemaID(test.gvk)) + }) + } +} + +func TestGVRToPluralName(t *testing.T) { + tests := []struct { + name string + gvr schema.GroupVersionResource + want string + }{ + { + name: "basic gvk", + gvr: schema.GroupVersionResource{ + Group: "TestGroup", + Version: "v1", + Resource: "TestResources", + }, + want: "TestGroup.TestResources", + }, + { + name: "core resource", + gvr: schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "TestResources", + }, + want: "TestResources", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, GVRToPluralName(test.gvr)) + }) + } +} + +func TestGetGVKForKind(t *testing.T) { + tests := []struct { + name string + kind *proto.Kind + wantGVK *schema.GroupVersionKind + }{ + { + name: "basic kind", + kind: &proto.Kind{ + BaseSchema: proto.BaseSchema{ + Extensions: map[string]any{ + gvkExtensionName: []any{ + "some other extension", + map[any]any{ + gvkExtensionGroup: "TestGroup", + gvkExtensionVersion: "v1", + gvkExtensionKind: "TestKind", + }, + }, + }, + }, + }, + wantGVK: &schema.GroupVersionKind{ + Group: "TestGroup", + Version: "v1", + Kind: "TestKind", + }, + }, + { + name: "kind missing gvkExtension", + kind: &proto.Kind{ + BaseSchema: proto.BaseSchema{ + Extensions: map[string]any{}, + }, + }, + wantGVK: nil, + }, + { + name: "kind missing gvk map", + kind: &proto.Kind{ + BaseSchema: proto.BaseSchema{ + Extensions: map[string]any{ + gvkExtensionName: []any{"some value"}, + }, + }, + }, + wantGVK: nil, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.wantGVK, GetGVKForKind(test.kind)) + }) + } + +} diff --git a/pkg/schema/converter/openapi.go b/pkg/schema/converter/openapi.go deleted file mode 100644 index 9ef16493..00000000 --- a/pkg/schema/converter/openapi.go +++ /dev/null @@ -1,117 +0,0 @@ -package converter - -import ( - "github.com/rancher/apiserver/pkg/types" - "github.com/rancher/steve/pkg/attributes" - "github.com/rancher/wrangler/pkg/data/convert" - "github.com/rancher/wrangler/pkg/schemas" - "github.com/sirupsen/logrus" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/discovery" - "k8s.io/kube-openapi/pkg/util/proto" -) - -func modelToSchema(modelName string, k *proto.Kind) *types.APISchema { - s := types.APISchema{ - Schema: &schemas.Schema{ - ID: modelName, - ResourceFields: map[string]schemas.Field{}, - Attributes: map[string]interface{}{}, - Description: k.GetDescription(), - }, - } - - for fieldName, schemaField := range k.Fields { - s.ResourceFields[fieldName] = toField(schemaField) - } - - for _, fieldName := range k.RequiredFields { - if f, ok := s.ResourceFields[fieldName]; ok { - f.Required = true - s.ResourceFields[fieldName] = f - } - } - - if ms, ok := k.Extensions["x-kubernetes-group-version-kind"].([]interface{}); ok { - for _, mv := range ms { - if m, ok := mv.(map[interface{}]interface{}); ok { - gvk := schema.GroupVersionKind{ - Group: convert.ToString(m["group"]), - Version: convert.ToString(m["version"]), - Kind: convert.ToString(m["kind"]), - } - - s.ID = GVKToVersionedSchemaID(gvk) - attributes.SetGVK(&s, gvk) - } - } - } - - for k, v := range s.ResourceFields { - if types.ReservedFields[k] { - s.ResourceFields["_"+k] = v - delete(s.ResourceFields, k) - } - } - - return &s -} - -func AddOpenAPI(client discovery.DiscoveryInterface, schemas map[string]*types.APISchema) error { - openapi, err := client.OpenAPISchema() - if err != nil { - return err - } - - models, err := proto.NewOpenAPIData(openapi) - if err != nil { - return err - } - - for _, modelName := range models.ListModels() { - model := models.LookupModel(modelName) - if k, ok := model.(*proto.Kind); ok { - schema := modelToSchema(modelName, k) - schemas[schema.ID] = schema - } - } - - return nil -} - -func toField(schema proto.Schema) schemas.Field { - f := schemas.Field{ - Description: schema.GetDescription(), - Create: true, - Update: true, - } - switch v := schema.(type) { - case *proto.Array: - f.Type = "array[" + toField(v.SubType).Type + "]" - case *proto.Primitive: - if v.Type == "number" || v.Type == "integer" { - f.Type = "int" - } else { - f.Type = v.Type - } - case *proto.Map: - f.Type = "map[" + toField(v.SubType).Type + "]" - case *proto.Kind: - f.Type = v.Path.String() - case proto.Reference: - sub := v.SubSchema() - if p, ok := sub.(*proto.Primitive); ok { - f.Type = p.Type - } else { - f.Type = sub.GetPath().String() - } - case *proto.Arbitrary: - logrus.Debugf("arbitrary type: %v", schema) - f.Type = "json" - default: - logrus.Errorf("unknown type: %v", schema) - f.Type = "json" - } - - return f -} diff --git a/pkg/schema/converter/openapiv3.go b/pkg/schema/converter/openapiv3.go deleted file mode 100644 index d9082dd6..00000000 --- a/pkg/schema/converter/openapiv3.go +++ /dev/null @@ -1,92 +0,0 @@ -package converter - -import ( - "github.com/rancher/apiserver/pkg/types" - "github.com/rancher/wrangler/pkg/schemas" - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" -) - -func modelV3ToSchema(name string, k *v1.JSONSchemaProps, schemasMap map[string]*types.APISchema) *types.APISchema { - s := types.APISchema{ - Schema: &schemas.Schema{ - ID: name, - ResourceFields: map[string]schemas.Field{}, - Attributes: map[string]interface{}{}, - Description: k.Description, - }, - } - - for fieldName, schemaField := range k.Properties { - s.ResourceFields[fieldName] = toResourceField(name+"."+fieldName, schemaField, schemasMap) - } - - for _, fieldName := range k.Required { - if f, ok := s.ResourceFields[fieldName]; ok { - f.Required = true - s.ResourceFields[fieldName] = f - } - } - - if existing, ok := schemasMap[s.ID]; ok && len(existing.Attributes) > 0 { - s.Attributes = existing.Attributes - } - schemasMap[s.ID] = &s - - for k, v := range s.ResourceFields { - if types.ReservedFields[k] { - s.ResourceFields["_"+k] = v - delete(s.ResourceFields, k) - } - } - - return &s -} - -func toResourceField(name string, schema v1.JSONSchemaProps, schemasMap map[string]*types.APISchema) schemas.Field { - f := schemas.Field{ - Description: schema.Description, - Nullable: true, - Create: true, - Update: true, - } - var itemSchema *v1.JSONSchemaProps - if schema.Items != nil { - if schema.Items.Schema != nil { - itemSchema = schema.Items.Schema - } else if len(schema.Items.JSONSchemas) > 0 { - itemSchema = &schema.Items.JSONSchemas[0] - } - } - - switch schema.Type { - case "array": - if itemSchema == nil { - f.Type = "array[json]" - } else if itemSchema.Type == "object" { - f.Type = "array[" + name + "]" - modelV3ToSchema(name, itemSchema, schemasMap) - } else { - f.Type = "array[" + itemSchema.Type + "]" - } - case "object": - if schema.AdditionalProperties != nil && schema.AdditionalProperties.Schema != nil && schema.AdditionalProperties.Schema.Type == "object" { - f.Type = "map[" + name + "]" - modelV3ToSchema(name, schema.AdditionalProperties.Schema, schemasMap) - } else if schema.AdditionalProperties != nil && schema.AdditionalProperties.Schema != nil { - f.Type = "map[" + schema.AdditionalProperties.Schema.Type + "]" - } else { - f.Type = name - modelV3ToSchema(name, &schema, schemasMap) - } - case "number": - f.Type = "int" - default: - f.Type = schema.Type - } - - if f.Type == "" { - f.Type = "json" - } - - return f -} diff --git a/pkg/schema/definitions/converter.go b/pkg/schema/definitions/converter.go new file mode 100644 index 00000000..14c88604 --- /dev/null +++ b/pkg/schema/definitions/converter.go @@ -0,0 +1,309 @@ +package definitions + +import ( + "fmt" + + "github.com/rancher/apiserver/pkg/types" + wranglerDefinition "github.com/rancher/wrangler/v3/pkg/schemas/definition" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/kube-openapi/pkg/util/proto" +) + +// crdToDefinition builds a schemaDefinition for a CustomResourceDefinition +func crdToDefinition(jsonSchemaProps *apiextv1.JSONSchemaProps, modelName string) (schemaDefinition, error) { + path := proto.NewPath(modelName) + + definitions, err := convertJSONSchemaPropsToDefinition(*jsonSchemaProps, path) + if err != nil { + return schemaDefinition{}, err + } + + return schemaDefinition{ + DefinitionType: modelName, + Definitions: definitions, + }, nil +} + +// convertJSONSchemaPropsToDefinition recurses through the given schema props of +// type object and adds each definition found to the map of definitions +// +// This supports all OpenAPI V3 types: boolean, number, integer, string, object and array +// as defined here: https://swagger.io/specification/v3/ +func convertJSONSchemaPropsToDefinition(props apiextv1.JSONSchemaProps, path proto.Path) (map[string]definition, error) { + definitions := make(map[string]definition) + _, err := convertJSONSchemaPropsObject(&props, path, definitions) + if err != nil { + return definitions, err + } + return definitions, nil +} + +func convertJSONSchemaProps(props *apiextv1.JSONSchemaProps, path proto.Path, definitions map[string]definition) (definitionField, error) { + if props.Type != "object" && props.Type != "array" { + return convertJSONSchemaPropsPrimitive(props), nil + } + + if props.Type == "array" { + return convertJSONSchemaPropsArray(props, path, definitions) + } + + if len(props.Properties) > 0 { + return convertJSONSchemaPropsObject(props, path, definitions) + } + + return convertJSONSchemaPropsMap(props, path, definitions) +} + +func convertJSONSchemaPropsObject(props *apiextv1.JSONSchemaProps, path proto.Path, definitions map[string]definition) (definitionField, error) { + field := definitionField{ + Description: props.Description, + Type: path.String(), + } + + // CRDs don't support references yet, but we guard against recursive + // lookups to be safe + if _, ok := definitions[path.String()]; ok { + return field, nil + } + + def := definition{ + Type: path.String(), + Description: props.Description, + ResourceFields: map[string]definitionField{}, + } + + requiredSet := make(map[string]struct{}) + for _, name := range props.Required { + requiredSet[name] = struct{}{} + } + + for name, prop := range props.Properties { + subField, err := convertJSONSchemaProps(&prop, path.FieldPath(name), definitions) + if err != nil { + return definitionField{}, err + } + + _, required := requiredSet[name] + subField.Required = required + def.ResourceFields[name] = subField + } + + definitions[path.String()] = def + + return field, nil +} + +func convertJSONSchemaPropsPrimitive(props *apiextv1.JSONSchemaProps) definitionField { + return definitionField{ + Description: props.Description, + Type: getPrimitiveType(props.Type), + } +} + +func convertJSONSchemaPropsArray(props *apiextv1.JSONSchemaProps, path proto.Path, definitions map[string]definition) (definitionField, error) { + field := definitionField{ + Description: props.Description, + Type: "array", + } + item := getItemsSchema(props) + if item == nil { + return definitionField{}, fmt.Errorf("array %q must have at least one item", path.String()) + } + + subField, err := convertJSONSchemaProps(item, path, definitions) + if err != nil { + return definitionField{}, err + } + + field.SubType = subField.Type + + return field, nil +} + +func convertJSONSchemaPropsMap(props *apiextv1.JSONSchemaProps, path proto.Path, definitions map[string]definition) (definitionField, error) { + field := definitionField{ + Description: props.Description, + Type: "map", + } + if props.AdditionalProperties != nil && props.AdditionalProperties.Schema != nil { + subField, err := convertJSONSchemaProps(props.AdditionalProperties.Schema, path, definitions) + if err != nil { + return definitionField{}, err + } + field.SubType = subField.Type + } else { + // Create the object in the definitions (won't recurse because + // by this point, we know props doesn't have any properties) + subField, err := convertJSONSchemaPropsObject(props, path, definitions) + if err != nil { + return definitionField{}, err + } + field.SubType = subField.Type + } + return field, nil + +} + +// typ is a OpenAPI V2 or V3 type +func getPrimitiveType(typ string) string { + switch typ { + case "integer", "number": + return "int" + default: + return typ + } +} + +func getItemsSchema(props *apiextv1.JSONSchemaProps) *apiextv1.JSONSchemaProps { + if props.Items == nil { + return nil + } + + if props.Items.Schema != nil { + return props.Items.Schema + } else if len(props.Items.JSONSchemas) > 0 { + // Copied from previous code in steve. Unclear if this path is + // ever taken because it seems to be unused even in k8s + // libraries and explicitly forbidden in CRDs + return &props.Items.JSONSchemas[0] + } + return nil +} + +// proto.Ref has unexported fields so we must implement our own proto.Reference +// type. +var _ proto.Reference = (*openAPIV2Reference)(nil) +var _ proto.Schema = (*openAPIV2Reference)(nil) + +// openAPIV2Reference will be visited by proto.Schema.Accept() as a +// proto.Reference +type openAPIV2Reference struct { + proto.BaseSchema + reference string + subSchema proto.Schema +} + +func (r *openAPIV2Reference) Accept(v proto.SchemaVisitor) { + v.VisitReference(r) +} + +func (r *openAPIV2Reference) Reference() string { + return r.reference +} + +func (r *openAPIV2Reference) SubSchema() proto.Schema { + return r.subSchema +} + +func (r *openAPIV2Reference) GetName() string { + return fmt.Sprintf("Reference to %q", r.reference) +} + +// mapToKind converts a *proto.Map to a *proto.Kind by keeping the same +// description, etc but also adding the 3 minimum fields - apiVersion, kind and +// metadata. +// This function assumes that the protoMap given is a top-level object (eg: a CRD). +func mapToKind(protoMap *proto.Map, models proto.Models) (*proto.Kind, error) { + apiVersion := &proto.Primitive{ + BaseSchema: proto.BaseSchema{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Path: protoMap.Path.FieldPath("apiVersion"), + }, + Type: "string", + } + kind := &proto.Primitive{ + BaseSchema: proto.BaseSchema{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Path: protoMap.Path.FieldPath("kind"), + }, + Type: "string", + } + objectMetaPath := "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + objectMetaModel := models.LookupModel(objectMetaPath) + if objectMetaModel == nil { + return nil, fmt.Errorf("OpenAPI V2 model %q not found", objectMetaPath) + } + metadata := &openAPIV2Reference{ + BaseSchema: proto.BaseSchema{ + Description: "Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata", + Path: protoMap.Path.FieldPath("metadata"), + }, + reference: objectMetaPath, + subSchema: objectMetaModel, + } + return &proto.Kind{ + BaseSchema: protoMap.BaseSchema, + Fields: map[string]proto.Schema{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": metadata, + }, + }, nil +} + +// openAPIV2ToDefinition builds a schemaDefinition for the given schemaID based on +// Resource information from OpenAPI v2 endpoint +func openAPIV2ToDefinition(protoSchema proto.Schema, models proto.Models, modelName string) (schemaDefinition, error) { + switch m := protoSchema.(type) { + case *proto.Map: + // If the schema is a *proto.Map, it will not have any Fields associated with it + // even though all Kubernetes resources have at least apiVersion, kind and metadata. + // + // We transform this Map to a Kind and inject these fields + var err error + protoSchema, err = mapToKind(m, models) + if err != nil { + return schemaDefinition{}, fmt.Errorf("convert map to kind: %w", err) + } + case *proto.Kind: + default: + return schemaDefinition{}, fmt.Errorf("model for %s was type %T, not a *proto.Kind nor *proto.Map", modelName, protoSchema) + } + definitions := map[string]definition{} + visitor := schemaFieldVisitor{ + definitions: definitions, + } + protoSchema.Accept(&visitor) + + return schemaDefinition{ + DefinitionType: modelName, + Definitions: definitions, + }, nil +} + +// baseSchemaToDefinition converts a given schema to the definition map. This should only be used with baseSchemas, whose definitions +// are expected to be set by another application and may not be k8s resources. +func baseSchemaToDefinition(schema types.APISchema) map[string]definition { + definitions := map[string]definition{} + def := definition{ + Description: schema.Description, + Type: schema.ID, + ResourceFields: map[string]definitionField{}, + } + for fieldName, field := range schema.ResourceFields { + fieldType, subType := parseFieldType(field.Type) + def.ResourceFields[fieldName] = definitionField{ + Type: fieldType, + SubType: subType, + Description: field.Description, + Required: field.Required, + } + } + definitions[schema.ID] = def + return definitions +} + +// parseFieldType parses a schemas.Field's type to a type (first return) and subType (second return) +func parseFieldType(fieldType string) (string, string) { + subType := wranglerDefinition.SubType(fieldType) + if wranglerDefinition.IsMapType(fieldType) { + return "map", subType + } + if wranglerDefinition.IsArrayType(fieldType) { + return "array", subType + } + if wranglerDefinition.IsReferenceType(fieldType) { + return "reference", subType + } + return fieldType, "" +} diff --git a/pkg/schema/definitions/converter_test.go b/pkg/schema/definitions/converter_test.go new file mode 100644 index 00000000..ca2472c2 --- /dev/null +++ b/pkg/schema/definitions/converter_test.go @@ -0,0 +1,429 @@ +package definitions + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +func TestCRDToDefinition(t *testing.T) { + tests := []struct { + name string + modelName string + // rawSchema is a JSON encoded OpenAPI V3 spec + // We use JSON instead of Go types because it's closer to what + // user are familiar with (JSON Schema in Go has some more fields + // like JSONSchemaPropsOrArray + rawSchema []byte + expectedSchemaDef schemaDefinition + wantError bool + }{ + { + name: "primitives", + modelName: "my.group.v1.Test", + rawSchema: []byte(` +{ + "type": "object", + "properties": { + "aStringWithoutDescription": { + "type": "string" + }, + "aString": { + "type": "string", + "description": "description of aString" + }, + "anInteger": { + "type": "integer", + "description": "description of anInteger" + }, + "aNumber": { + "type": "number", + "description": "description of aNumber" + }, + "aBoolean": { + "type": "boolean", + "description": "description of aBoolean" + } + } +}`), + expectedSchemaDef: schemaDefinition{ + DefinitionType: "my.group.v1.Test", + Definitions: map[string]definition{ + "my.group.v1.Test": { + Type: "my.group.v1.Test", + ResourceFields: map[string]definitionField{ + "aStringWithoutDescription": { + Type: "string", + }, + "aString": { + Type: "string", + Description: "description of aString", + }, + "anInteger": { + Type: "int", + Description: "description of anInteger", + }, + "aNumber": { + Type: "int", + Description: "description of aNumber", + }, + "aBoolean": { + Type: "boolean", + Description: "description of aBoolean", + }, + }, + }, + }, + }, + }, + { + name: "arrays", + modelName: "my.group.v1.Test", + rawSchema: []byte(` +{ + "type": "object", + "properties": { + "anArrayOfString": { + "type": "array", + "items": { + "type": "string" + } + }, + "anArrayOfInteger": { + "type": "array", + "items": { + "type": "integer" + } + }, + "anArrayOfNumber": { + "type": "array", + "items": { + "type": "number" + } + }, + "anArrayOfBoolean": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "anArrayOfObject": { + "type": "array", + "items": { + "type": "object", + "properties": { + "foo": { + "type": "string" + } + } + } + }, + "anArrayOfMap": { + "type": "array", + "items": { + "type": "object" + } + }, + "anArrayWithListOfItems": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + } +}`), + expectedSchemaDef: schemaDefinition{ + DefinitionType: "my.group.v1.Test", + Definitions: map[string]definition{ + "my.group.v1.Test": { + Type: "my.group.v1.Test", + ResourceFields: map[string]definitionField{ + "anArrayOfString": { + Type: "array", + SubType: "string", + }, + "anArrayOfInteger": { + Type: "array", + SubType: "int", + }, + "anArrayOfNumber": { + Type: "array", + SubType: "int", + }, + "anArrayOfBoolean": { + Type: "array", + SubType: "boolean", + }, + "anArrayOfObject": { + Type: "array", + SubType: "my.group.v1.Test.anArrayOfObject", + }, + "anArrayOfMap": { + Type: "array", + SubType: "map", + }, + "anArrayWithListOfItems": { + Type: "array", + SubType: "string", + }, + }, + }, + "my.group.v1.Test.anArrayOfObject": { + Type: "my.group.v1.Test.anArrayOfObject", + ResourceFields: map[string]definitionField{ + "foo": { + Type: "string", + }, + }, + }, + // Currently not referenced in my.group.v1.Test due to lack of support for nested array + // but will be useful once we get this in + "my.group.v1.Test.anArrayOfMap": { + Type: "my.group.v1.Test.anArrayOfMap", + ResourceFields: map[string]definitionField{}, + }, + }, + }, + }, + { + name: "nested objects", + modelName: "my.group.v1.Test", + rawSchema: []byte(` +{ + "type": "object", + "properties": { + "grandparent": { + "type": "object", + "properties": { + "parent": { + "type": "object", + "properties": { + "child": { + "type": "string" + } + } + } + } + } + } +}`), + expectedSchemaDef: schemaDefinition{ + DefinitionType: "my.group.v1.Test", + Definitions: map[string]definition{ + "my.group.v1.Test": { + Type: "my.group.v1.Test", + ResourceFields: map[string]definitionField{ + "grandparent": { + Type: "my.group.v1.Test.grandparent", + }, + }, + }, + "my.group.v1.Test.grandparent": { + Type: "my.group.v1.Test.grandparent", + ResourceFields: map[string]definitionField{ + "parent": { + Type: "my.group.v1.Test.grandparent.parent", + }, + }, + }, + "my.group.v1.Test.grandparent.parent": { + Type: "my.group.v1.Test.grandparent.parent", + ResourceFields: map[string]definitionField{ + "child": { + Type: "string", + }, + }, + }, + }, + }, + }, + { + name: "nested arrays", + modelName: "my.group.v1.Test", + rawSchema: []byte(` +{ + "type": "object", + "properties": { + "anArrayOfArrayOfString": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + } +}`), + expectedSchemaDef: schemaDefinition{ + DefinitionType: "my.group.v1.Test", + Definitions: map[string]definition{ + "my.group.v1.Test": { + Type: "my.group.v1.Test", + ResourceFields: map[string]definitionField{ + "anArrayOfArrayOfString": { + Type: "array", + SubType: "array", + }, + }, + }, + }, + }, + }, + { + name: "maps in object", + modelName: "my.group.v1.Test", + rawSchema: []byte(` +{ + "type": "object", + "properties": { + "mapEmpty": { + "type": "object" + }, + "mapAdditionalPropertiesObject": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "foo": { + "type": "string" + } + } + } + }, + "mapAdditionalPropertiesPrimitive": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } +}`), + expectedSchemaDef: schemaDefinition{ + DefinitionType: "my.group.v1.Test", + Definitions: map[string]definition{ + "my.group.v1.Test": { + Type: "my.group.v1.Test", + ResourceFields: map[string]definitionField{ + "mapEmpty": { + Type: "map", + SubType: "my.group.v1.Test.mapEmpty", + }, + "mapAdditionalPropertiesObject": { + Type: "map", + SubType: "my.group.v1.Test.mapAdditionalPropertiesObject", + }, + "mapAdditionalPropertiesPrimitive": { + Type: "map", + SubType: "string", + }, + }, + }, + "my.group.v1.Test.mapEmpty": { + Type: "my.group.v1.Test.mapEmpty", + ResourceFields: map[string]definitionField{}, + }, + "my.group.v1.Test.mapAdditionalPropertiesObject": { + Type: "my.group.v1.Test.mapAdditionalPropertiesObject", + ResourceFields: map[string]definitionField{ + "foo": { + Type: "string", + }, + }, + }, + }, + }, + }, + { + name: "required fields", + modelName: "my.group.v1.Test", + rawSchema: []byte(` +{ + "type": "object", + "required": [ + "topLevelRequired" + ], + "properties": { + "topLevelRequired": { + "type": "string" + }, + "child": { + "type": "object", + "required": [ + "fieldIsRequired" + ], + "properties": { + "fieldIsRequired": { + "type": "string" + } + } + } + } +}`), + expectedSchemaDef: schemaDefinition{ + DefinitionType: "my.group.v1.Test", + Definitions: map[string]definition{ + "my.group.v1.Test": { + Type: "my.group.v1.Test", + ResourceFields: map[string]definitionField{ + "topLevelRequired": { + Type: "string", + Required: true, + }, + "child": { + Type: "my.group.v1.Test.child", + }, + }, + }, + "my.group.v1.Test.child": { + Type: "my.group.v1.Test.child", + ResourceFields: map[string]definitionField{ + "fieldIsRequired": { + Type: "string", + Required: true, + }, + }, + }, + }, + }, + }, + { + name: "bad array", + modelName: "my.group.v1.Test", + rawSchema: []byte(` +{ + "type": "object", + "properties": { + "badArray": { + "type": "array" + } + } +}`), + wantError: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var schema apiextv1.JSONSchemaProps + err := json.Unmarshal(test.rawSchema, &schema) + require.NoError(t, err) + + schemaDef, err := crdToDefinition(&schema, test.modelName) + + if test.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, test.expectedSchemaDef, schemaDef) + } + }) + } + +} diff --git a/pkg/schema/definitions/fixtures_test.go b/pkg/schema/definitions/fixtures_test.go new file mode 100644 index 00000000..afc4f811 --- /dev/null +++ b/pkg/schema/definitions/fixtures_test.go @@ -0,0 +1,400 @@ +package definitions + +import ( + "bytes" + "fmt" + + "github.com/rancher/wrangler/v3/pkg/yaml" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +var ( + rawCRDs = `apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: userattributes.management.cattle.io +spec: + conversion: + strategy: None + group: management.cattle.io + names: + kind: UserAttribute + listKind: UserAttributeList + plural: userattributes + singular: userattribute + scope: Cluster + versions: + - name: v2 + schema: + openAPIV3Schema: + type: object + x-kubernetes-preserve-unknown-fields: true + served: true + storage: true +--- +kind: CustomResourceDefinition +metadata: + name: nullable.management.cattle.io +spec: + conversion: + strategy: None + group: management.cattle.io + names: + kind: Nullable + listKind: NullableList + plural: nullables + singular: nullable + scope: Cluster + versions: + - name: v2 + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + rkeConfig: + type: object + nullable: true + properties: + additionalManifest: + type: string + nullable: true + served: true + storage: true +` +) + +func getCRDs() ([]*apiextv1.CustomResourceDefinition, error) { + crds, err := yaml.UnmarshalWithJSONDecoder[*apiextv1.CustomResourceDefinition](bytes.NewBuffer([]byte(rawCRDs))) + if err != nil { + return nil, fmt.Errorf("unmarshal CRD: %w", err) + } + return crds, err +} + +const openapi_raw = ` +swagger: "2.0" +info: + title: "Test openapi spec" + version: "v1.0.0" +paths: + /apis/management.cattle.io/v3/globalroles: + get: + description: "get a global role" + responses: + 200: + description: "OK" +definitions: + io.cattle.management.v1.GlobalRole: + description: "A Global Role V1 provides Global Permissions in Rancher" + type: "object" + properties: + apiVersion: + description: "The APIVersion of this resource" + type: "string" + kind: + description: "The kind" + type: "string" + metadata: + description: "The metadata" + $ref: "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + spec: + description: "The spec for the project" + type: "object" + required: + - "clusterName" + - "displayName" + properties: + clusterName: + description: "The name of the cluster" + type: "string" + displayName: + description: "The UI readable name" + type: "string" + notRequired: + description: "Some field that isn't required" + type: "boolean" + x-kubernetes-group-version-kind: + - group: "management.cattle.io" + version: "v1" + kind: "GlobalRole" + io.cattle.management.v2.GlobalRole: + description: "A Global Role V2 provides Global Permissions in Rancher" + type: "object" + properties: + apiVersion: + description: "The APIVersion of this resource" + type: "string" + kind: + description: "The kind" + type: "string" + metadata: + description: "The metadata" + $ref: "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + spec: + description: "The spec for the project" + type: "object" + required: + - "clusterName" + - "displayName" + properties: + clusterName: + description: "The name of the cluster" + type: "string" + displayName: + description: "The UI readable name" + type: "string" + notRequired: + description: "Some field that isn't required" + type: "boolean" + newField: + description: "A new field not present in v1" + type: "string" + x-kubernetes-group-version-kind: + - group: "management.cattle.io" + version: "v2" + kind: "GlobalRole" + io.cattle.management.v2.NewResource: + description: "A resource that's in the v2 group, but not v1" + type: "object" + properties: + apiVersion: + description: "The APIVersion of this resource" + type: "string" + kind: + description: "The kind" + type: "string" + metadata: + description: "The metadata" + $ref: "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + spec: + description: "The spec for the new resource" + type: "object" + required: + - "someRequired" + properties: + someRequired: + description: "A required field" + type: "string" + notRequired: + description: "Some field that isn't required" + type: "boolean" + x-kubernetes-group-version-kind: + - group: "management.cattle.io" + version: "v2" + kind: "NewResource" + io.cattle.noversion.v2.Resource: + description: "A No Version V2 resource is for a group with no preferred version" + type: "object" + properties: + apiVersion: + description: "The APIVersion of this resource" + type: "string" + kind: + description: "The kind" + type: "string" + metadata: + description: "The metadata" + $ref: "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + spec: + description: "The spec for the resource" + type: "object" + required: + - "name" + properties: + name: + description: "The name of the resource" + type: "string" + notRequired: + description: "Some field that isn't required" + type: "boolean" + newField: + description: "A new field not present in v1" + type: "string" + x-kubernetes-group-version-kind: + - group: "noversion.cattle.io" + version: "v2" + kind: "Resource" + io.cattle.noversion.v1.Resource: + description: "A No Version V1 resource is for a group with no preferred version" + type: "object" + properties: + apiVersion: + description: "The APIVersion of this resource" + type: "string" + kind: + description: "The kind" + type: "string" + metadata: + description: "The metadata" + $ref: "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + spec: + description: "The spec for the resource" + type: "object" + required: + - "name" + properties: + name: + description: "The name of the resource" + type: "string" + notRequired: + description: "Some field that isn't required" + type: "boolean" + x-kubernetes-group-version-kind: + - group: "noversion.cattle.io" + version: "v1" + kind: "Resource" + io.cattle.management.v1.DeprecatedResource: + description: "A resource that is not present in v2" + type: "object" + properties: + apiVersion: + description: "The APIVersion of this resource" + type: "string" + kind: + description: "The kind" + type: "string" + metadata: + description: "The metadata" + $ref: "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + x-kubernetes-group-version-kind: + - group: "management.cattle.io" + version: "v1" + kind: "DeprecatedResource" + io.cattle.missinggroup.v2.Resource: + description: "A Missing Group V2 resource is for a group not listed by server groups" + type: "object" + properties: + apiVersion: + description: "The APIVersion of this resource" + type: "string" + kind: + description: "The kind" + type: "string" + metadata: + description: "The metadata" + $ref: "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + spec: + description: "The spec for the resource" + type: "object" + required: + - "name" + properties: + name: + description: "The name of the resource" + type: "string" + notRequired: + description: "Some field that isn't required" + type: "boolean" + newField: + description: "A new field not present in v1" + type: "string" + x-kubernetes-group-version-kind: + - group: "missinggroup.cattle.io" + version: "v2" + kind: "Resource" + io.cattle.missinggroup.v1.Resource: + description: "A Missing Group V1 resource is for a group not listed by server groups" + type: "object" + properties: + apiVersion: + description: "The APIVersion of this resource" + type: "string" + kind: + description: "The kind" + type: "string" + metadata: + description: "The metadata" + $ref: "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + spec: + description: "The spec for the resource" + type: "object" + required: + - "name" + properties: + name: + description: "The name of the resource" + type: "string" + notRequired: + description: "Some field that isn't required" + type: "boolean" + x-kubernetes-group-version-kind: + - group: "missinggroup.cattle.io" + version: "v1" + kind: "Resource" + io.cattle.management.v2.Nullable: + type: "object" + description: "" + properties: + apiVersion: + description: "The APIVersion of this resource" + type: "string" + kind: + description: "The kind" + type: "string" + metadata: + description: "The metadata" + $ref: "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + spec: + description: "" + type: "object" + properties: + rkeConfig: + x-kubernetes-group-version-kind: + - group: "management.cattle.io" + version: "v2" + kind: "Nullable" + io.cattle.management.NotAKind: + type: "string" + description: "Some string which isn't a kind" + io.cattle.management.v2.UserAttribute: + type: "object" + x-kubernetes-group-version-kind: + - group: "management.cattle.io" + version: "v2" + kind: "UserAttribute" + io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta: + description: "Object Metadata" + properties: + annotations: + description: "annotations of the resource" + type: "object" + additionalProperties: + type: "string" + name: + description: "name of the resource" + type: "string" + io.k8s.api.core.v1.ConfigMap: + type: "object" + description: "ConfigMap holds configuration data for pods to consume." + properties: + apiVersion: + description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources" + type: "string" + kind: + description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds" + type: "string" + metadata: + description: "Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata" + $ref: "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + binaryData: + description: "BinaryData contains the binary data. Each key must consist of alphanumeric characters, '-', '_' or '.'. BinaryData can contain byte sequences that are not in the UTF-8 range. The keys stored in BinaryData must not overlap with the ones in the Data field, this is enforced during validation process. Using this field will require 1.10+ apiserver and kubelet." + type: "object" + additionalProperties: + type: "string" + format: "byte" + data: + description: "Data contains the configuration data. Each key must consist of alphanumeric characters, '-', '_' or '.'. Values with non-UTF-8 byte sequences must use the BinaryData field. The keys stored in Data must not overlap with the keys in the BinaryData field, this is enforced during validation process." + type: "object" + additionalProperties: + type: "string" + immutable: + description: "Immutable, if set to true, ensures that data stored in the ConfigMap cannot be updated (only object metadata can be modified). If not set to true, the field can be modified at any time. Defaulted to nil." + type: "boolean" + x-kubernetes-group-version-kind: + - group: "" + kind: "ConfigMap" + version: "v1" +` diff --git a/pkg/schema/definitions/handler.go b/pkg/schema/definitions/handler.go new file mode 100644 index 00000000..d45eeedf --- /dev/null +++ b/pkg/schema/definitions/handler.go @@ -0,0 +1,269 @@ +package definitions + +import ( + "fmt" + "net/http" + "sync" + + "github.com/rancher/apiserver/pkg/apierror" + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/steve/pkg/schema/converter" + wapiextv1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/apiextensions.k8s.io/v1" + "github.com/rancher/wrangler/v3/pkg/schemas/validation" + "github.com/sirupsen/logrus" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "k8s.io/kube-openapi/pkg/util/proto" +) + +var ( + internalServerErrorCode = validation.ErrorCode{ + Status: http.StatusInternalServerError, + Code: "InternalServerError", + } + notRefreshedErrorCode = validation.ErrorCode{ + Status: http.StatusServiceUnavailable, + Code: "SchemasNotRefreshed", + } +) + +type gvkModel struct { + // ModelName is the name of the OpenAPI V2 model. + // For example, the GVK Group=management.cattle.io/v2, Kind=UserAttribute will have + // the following model name: io.cattle.management.v2.UserAttribute. + ModelName string + // Schema is the OpenAPI V2 schema for this GVK + Schema proto.Schema + // CRD is the schema from the CRD for this GVK, if there exists a CRD + // for this group/kind + CRD *apiextv1.JSONSchemaProps +} + +// SchemaDefinitionHandler provides a schema definition for the schema ID provided. The schema definition is built +// using the following information, in order: +// +// 1. If the schema ID refers to a BaseSchema (a schema that doesn't exist in Kubernetes), then we use the +// schema to return the schema definition - we return early. Otherwise: +// 2. We build a schema definition from the OpenAPI V2 info. +// 3. If the schemaID refers to a CRD, then we also build a schema definition from the CRD. +// 4. We merge both the OpenAPI V2 and the CRD schema definition. CRD will ALWAYS override whatever is +// in OpenAPI V2. This makes sense because CRD is defined by OpenAPI V3, so has more information. This +// merged schema definition is returned. +// +// Note: SchemaDefinitionHandler only implements a ByID handler. It does not implement any method allowing a caller +// to list definitions for all schemas. +type SchemaDefinitionHandler struct { + // gvkModels maps a schema ID (eg: management.cattle.io.userattributes) to + // the computed and cached gvkModel. It is recomputed on `Refresh()`. + gvkModels map[string]gvkModel + // models are the cached models from the last response from kubernetes. + models proto.Models + // lock protects gvkModels and models which are updated in Refresh + lock sync.RWMutex + + // baseSchema are the schemas (which may not represent a real CRD) added to the server + baseSchema *types.APISchemas + + // crdCache is used to add more information to a schema definition by getting information + // from the CRD of the resource being accessed (if said resource is a CRD) + crdCache wapiextv1.CustomResourceDefinitionCache + + // client is the discovery client used to get the groups/resources/fields from kubernetes + client discovery.DiscoveryInterface +} + +func NewSchemaDefinitionHandler( + baseSchema *types.APISchemas, + crdCache wapiextv1.CustomResourceDefinitionCache, + client discovery.DiscoveryInterface, +) *SchemaDefinitionHandler { + handler := &SchemaDefinitionHandler{ + baseSchema: baseSchema, + crdCache: crdCache, + client: client, + } + return handler +} + +// Refresh writeLocks and updates the cache with new schemaDefinitions. Will result in a call to kubernetes to retrieve +// the openAPI schemas. +func (s *SchemaDefinitionHandler) Refresh() error { + openapi, err := s.client.OpenAPISchema() + if err != nil { + return fmt.Errorf("unable to fetch openapi v2 definition: %w", err) + } + models, err := proto.NewOpenAPIData(openapi) + if err != nil { + return fmt.Errorf("unable to parse openapi definition into models: %w", err) + } + groups, err := s.client.ServerGroups() + if err != nil { + return fmt.Errorf("unable to retrieve groups: %w", err) + } + + gvkModels, err := listGVKModels(models, groups, s.crdCache) + if err != nil { + return err + } + + s.lock.Lock() + defer s.lock.Unlock() + s.gvkModels = gvkModels + s.models = models + return nil +} + +// byIDHandler is the Handler method for a request to get the schema definition for a specific schema. Will use the +// cached models found during the last refresh as part of this process. +func (s *SchemaDefinitionHandler) byIDHandler(request *types.APIRequest) (types.APIObject, error) { + // pseudo-access check, designed to make sure that users have access to the schema for the definition that they + // are accessing. + requestSchema := request.Schemas.LookupSchema(request.Name) + if requestSchema == nil { + return types.APIObject{}, apierror.NewAPIError(validation.NotFound, "no such schema") + } + + if baseSchema := s.baseSchema.LookupSchema(requestSchema.ID); baseSchema != nil { + // if this schema is a base schema it won't be in the model cache. In this case, and only this case, we process + // the fields independently + definitions := baseSchemaToDefinition(*requestSchema) + return types.APIObject{ + ID: request.Name, + Type: "schemaDefinition", + Object: schemaDefinition{ + DefinitionType: requestSchema.ID, + Definitions: definitions, + }, + }, nil + } + + s.lock.RLock() + gvkModels := s.gvkModels + protoModels := s.models + s.lock.RUnlock() + + if gvkModels == nil || protoModels == nil { + return types.APIObject{}, apierror.NewAPIError(notRefreshedErrorCode, "schema definitions not yet refreshed") + } + + model, ok := gvkModels[requestSchema.ID] + if !ok { + return types.APIObject{}, apierror.NewAPIError(notRefreshedErrorCode, "no model found for schema, try again after refresh") + } + + schemaDef, err := buildSchemaDefinitionForModel(protoModels, model) + if err != nil { + logrus.Errorf("failed building schema definition for model %s: %s", model.ModelName, err) + return types.APIObject{}, apierror.NewAPIError(internalServerErrorCode, "failed building schema definition") + } + + return types.APIObject{ + ID: request.Name, + Type: "schemaDefinition", + Object: schemaDef, + }, nil +} + +func buildSchemaDefinitionForModel(models proto.Models, gvk gvkModel) (schemaDefinition, error) { + definitions, err := openAPIV2ToDefinition(gvk.Schema, models, gvk.ModelName) + if err != nil { + return schemaDefinition{}, fmt.Errorf("OpenAPI V2 to definition error: %w", err) + } + + // CRDs don't always exists (eg: Pods, Deployments, etc) + if gvk.CRD != nil { + // CRD definitions generally has more information than the OpenAPI V2 + // because it embeds an OpenAPI V3 document. However, these 3 fields + // are the exception where the Open API V2 endpoint has more + // information. + props := gvk.CRD.DeepCopy() + delete(props.Properties, "apiVersion") + delete(props.Properties, "kind") + delete(props.Properties, "metadata") + + // We want to merge the OpenAPI V2 information with the CRD information + // whenever possible because the CRD is defined by OpenAPI V3 which + // _generally_ ends up with more information than OpenAPI V2 + // (eg: Optional fields wrongly ends up as type string in V2) + crdDefinitions, err := crdToDefinition(props, gvk.ModelName) + if err != nil { + return schemaDefinition{}, fmt.Errorf("failed converting CRD to schema definition: %w", err) + } + + if err := definitions.Merge(crdDefinitions); err != nil { + return schemaDefinition{}, fmt.Errorf("merging V2 and CRD definition: %w", err) + } + } + + return definitions, nil +} + +// listGVKModels returns a map of schemaID to the gvkModel. Will use the preferred version of a +// resource if possible. +func listGVKModels(models proto.Models, groups *metav1.APIGroupList, crdCache wapiextv1.CustomResourceDefinitionCache) (map[string]gvkModel, error) { + groupToPreferredVersion := make(map[string]string) + if groups != nil { + for _, group := range groups.Groups { + groupToPreferredVersion[group.Name] = group.PreferredVersion.Version + } + } + + gvkToCRD := make(map[schema.GroupVersionKind]*apiextv1.JSONSchemaProps) + crds, err := crdCache.List(labels.Everything()) + if err != nil { + return nil, err + } + for _, crd := range crds { + for _, version := range crd.Spec.Versions { + gvk := schema.GroupVersionKind{ + Group: crd.Spec.Group, + Version: version.Name, + Kind: crd.Spec.Names.Kind, + } + gvkToCRD[gvk] = version.Schema.OpenAPIV3Schema + } + } + + schemaToGVKModel := map[string]gvkModel{} + for _, modelName := range models.ListModels() { + protoSchema := models.LookupModel(modelName) + switch protoSchema.(type) { + // It is possible that a Kubernetes resources ends up being treated as + // a *proto.Map instead of *proto.Kind when it doesn't have any fields + // defined. (eg: management.cattle.io.v1.UserAttributes) + // + // For that reason, we accept both *proto.Kind and *proto.Map + // as long as they have a GVK assigned + case *proto.Kind, *proto.Map: + default: + // no need to process models that aren't kind or map + continue + } + + // Makes sure the schema has a GVK (whether it's a Map or a Kind) + gvk := converter.GetGVKForProtoSchema(protoSchema) + if gvk == nil { + continue + } + + schemaID := converter.GVKToSchemaID(*gvk) + + prefVersion := groupToPreferredVersion[gvk.Group] + _, ok := schemaToGVKModel[schemaID] + // we always add the preferred version to the map. However, if this isn't the preferred version the preferred group could + // be missing this resource (e.x. v1alpha1 has a resource, it's removed in v1). In those cases, we add the model name + // only if we don't already have an entry. This way we always choose the preferred, if possible, but still have 1 version + // for everything + if !ok || prefVersion == gvk.Version { + schemaToGVKModel[schemaID] = gvkModel{ + ModelName: modelName, + Schema: protoSchema, + CRD: gvkToCRD[*gvk], + } + } + } + return schemaToGVKModel, nil +} diff --git a/pkg/schema/definitions/handler_test.go b/pkg/schema/definitions/handler_test.go new file mode 100644 index 00000000..2010f0ce --- /dev/null +++ b/pkg/schema/definitions/handler_test.go @@ -0,0 +1,787 @@ +package definitions + +import ( + "fmt" + "testing" + + "github.com/golang/mock/gomock" + openapi_v2 "github.com/google/gnostic-models/openapiv2" + "github.com/rancher/apiserver/pkg/apierror" + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/wrangler/v3/pkg/generic/fake" + wschemas "github.com/rancher/wrangler/v3/pkg/schemas" + "github.com/stretchr/testify/require" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/version" + "k8s.io/client-go/discovery" + "k8s.io/client-go/openapi" + restclient "k8s.io/client-go/rest" + "k8s.io/kube-openapi/pkg/util/proto" +) + +func TestRefresh(t *testing.T) { + defaultDocument, err := openapi_v2.ParseDocument([]byte(openapi_raw)) + require.NoError(t, err) + defaultModels, err := proto.NewOpenAPIData(defaultDocument) + require.NoError(t, err) + + crds, err := getCRDs() + require.NoError(t, err) + + userAttributesV2 := getJSONSchema(crds, "userattributes.management.cattle.io", "v2") + require.NotNil(t, userAttributesV2) + + nullableV2 := getJSONSchema(crds, "nullable.management.cattle.io", "v2") + require.NotNil(t, userAttributesV2) + + tests := []struct { + name string + openapiError error + crdListError error + serverGroupsErr error + useBadOpenApiDoc bool + nilGroups bool + wantModels proto.Models + wantGVKModels map[string]gvkModel + wantError bool + }{ + { + name: "success", + wantModels: defaultModels, + wantGVKModels: map[string]gvkModel{ + "configmap": { + ModelName: "io.k8s.api.core.v1.ConfigMap", + Schema: defaultModels.LookupModel("io.k8s.api.core.v1.ConfigMap"), + }, + "management.cattle.io.deprecatedresource": { + ModelName: "io.cattle.management.v1.DeprecatedResource", + Schema: defaultModels.LookupModel("io.cattle.management.v1.DeprecatedResource"), + }, + "management.cattle.io.globalrole": { + ModelName: "io.cattle.management.v2.GlobalRole", + Schema: defaultModels.LookupModel("io.cattle.management.v2.GlobalRole"), + }, + "management.cattle.io.newresource": { + ModelName: "io.cattle.management.v2.NewResource", + Schema: defaultModels.LookupModel("io.cattle.management.v2.NewResource"), + }, + "noversion.cattle.io.resource": { + ModelName: "io.cattle.noversion.v1.Resource", + Schema: defaultModels.LookupModel("io.cattle.noversion.v1.Resource"), + }, + "missinggroup.cattle.io.resource": { + ModelName: "io.cattle.missinggroup.v1.Resource", + Schema: defaultModels.LookupModel("io.cattle.missinggroup.v1.Resource"), + }, + "management.cattle.io.userattribute": { + ModelName: "io.cattle.management.v2.UserAttribute", + Schema: defaultModels.LookupModel("io.cattle.management.v2.UserAttribute"), + CRD: userAttributesV2, + }, + "management.cattle.io.nullable": { + ModelName: "io.cattle.management.v2.Nullable", + Schema: defaultModels.LookupModel("io.cattle.management.v2.Nullable"), + CRD: nullableV2, + }, + }, + }, + { + name: "error - openapi doc unavailable", + openapiError: fmt.Errorf("server unavailable"), + wantError: true, + }, + { + name: "error - crd cache list error", + crdListError: fmt.Errorf("error from cache"), + wantError: true, + }, + { + name: "error - unable to parse openapi doc", + useBadOpenApiDoc: true, + wantError: true, + }, + { + name: "error - unable to retrieve groups and resources", + serverGroupsErr: fmt.Errorf("server not available"), + wantError: true, + }, + { + name: "no groups or error from server", + nilGroups: true, + wantModels: defaultModels, + wantGVKModels: map[string]gvkModel{ + "configmap": { + ModelName: "io.k8s.api.core.v1.ConfigMap", + Schema: defaultModels.LookupModel("io.k8s.api.core.v1.ConfigMap"), + }, + "management.cattle.io.deprecatedresource": { + ModelName: "io.cattle.management.v1.DeprecatedResource", + Schema: defaultModels.LookupModel("io.cattle.management.v1.DeprecatedResource"), + }, + // GlobalRole is now v1 instead of v2 + "management.cattle.io.globalrole": { + ModelName: "io.cattle.management.v1.GlobalRole", + Schema: defaultModels.LookupModel("io.cattle.management.v1.GlobalRole"), + }, + "management.cattle.io.newresource": { + ModelName: "io.cattle.management.v2.NewResource", + Schema: defaultModels.LookupModel("io.cattle.management.v2.NewResource"), + }, + "noversion.cattle.io.resource": { + ModelName: "io.cattle.noversion.v1.Resource", + Schema: defaultModels.LookupModel("io.cattle.noversion.v1.Resource"), + }, + "missinggroup.cattle.io.resource": { + ModelName: "io.cattle.missinggroup.v1.Resource", + Schema: defaultModels.LookupModel("io.cattle.missinggroup.v1.Resource"), + }, + "management.cattle.io.userattribute": { + ModelName: "io.cattle.management.v2.UserAttribute", + Schema: defaultModels.LookupModel("io.cattle.management.v2.UserAttribute"), + CRD: userAttributesV2, + }, + "management.cattle.io.nullable": { + ModelName: "io.cattle.management.v2.Nullable", + Schema: defaultModels.LookupModel("io.cattle.management.v2.Nullable"), + CRD: nullableV2, + }, + }, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + client, err := buildDefaultDiscovery() + client.DocumentErr = test.openapiError + client.GroupsErr = test.serverGroupsErr + if test.useBadOpenApiDoc { + schema := client.Document.Definitions.AdditionalProperties[0] + schema.Value.Type = &openapi_v2.TypeItem{ + Value: []string{"multiple", "entries"}, + } + } + if test.nilGroups { + client.Groups = nil + } + require.Nil(t, err) + baseSchemas := types.EmptyAPISchemas() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + crds, err := getCRDs() + require.NoError(t, err) + crdCache := fake.NewMockNonNamespacedCacheInterface[*apiextv1.CustomResourceDefinition](ctrl) + if test.crdListError != nil { + crdCache.EXPECT().List(labels.Everything()).Return(nil, test.crdListError).AnyTimes() + } else { + crdCache.EXPECT().List(labels.Everything()).Return(crds, nil).AnyTimes() + } + + handler := NewSchemaDefinitionHandler(baseSchemas, crdCache, client) + err = handler.Refresh() + if test.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + handler.lock.RLock() + defer handler.lock.RUnlock() + require.Equal(t, test.wantModels, handler.models) + require.Equal(t, test.wantGVKModels, handler.gvkModels) + }) + + } +} + +func Test_byID(t *testing.T) { + discoveryClient, err := buildDefaultDiscovery() + require.NoError(t, err) + + schemas := types.EmptyAPISchemas() + addSchema := func(names ...string) { + for _, name := range names { + schemas.MustAddSchema(types.APISchema{ + Schema: &wschemas.Schema{ + ID: name, + CollectionMethods: []string{"get"}, + ResourceMethods: []string{"get"}, + }, + }) + } + } + + intPtr := func(input int) *int { + return &input + } + builtinSchema := types.APISchema{ + Schema: &wschemas.Schema{ + ID: "builtin", + Description: "some builtin type", + CollectionMethods: []string{"get"}, + ResourceMethods: []string{"get"}, + ResourceFields: map[string]wschemas.Field{ + "complex": { + Type: "map[string]", + Description: "some complex field", + }, + "complexArray": { + Type: "array[string]", + Description: "some complex array field", + }, + "complexRef": { + Type: "reference[complex]", + Description: "some complex reference field", + }, + "simple": { + Type: "string", + Description: "some simple field", + Required: true, + }, + "leftBracket": { + Type: "test[", + Description: "some field with a open bracket but no close bracket", + }, + }, + }, + } + addSchema( + "configmap", + "management.cattle.io.globalrole", + "management.cattle.io.missingfrommodel", + "management.cattle.io.notakind", + "management.cattle.io.nullable", + "management.cattle.io.userattribute", + "management.cattle.io.deprecatedresource", + ) + baseSchemas := types.EmptyAPISchemas() + baseSchemas.MustAddSchema(builtinSchema) + schemas.MustAddSchema(builtinSchema) + + tests := []struct { + name string + schemaName string + skipRefresh bool + wantObject *types.APIObject + wantError bool + wantErrorCode *int + }{ + { + // ConfigMaps is NOT a CRD but it is defined in OpenAPI V2 + name: "configmap", + schemaName: "configmap", + wantObject: &types.APIObject{ + ID: "configmap", + Type: "schemaDefinition", + Object: schemaDefinition{ + DefinitionType: "io.k8s.api.core.v1.ConfigMap", + Definitions: map[string]definition{ + "io.k8s.api.core.v1.ConfigMap": { + Type: "io.k8s.api.core.v1.ConfigMap", + Description: "ConfigMap holds configuration data for pods to consume.", + ResourceFields: map[string]definitionField{ + "apiVersion": { + Type: "string", + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + }, + "kind": { + Type: "string", + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + }, + "metadata": { + Type: "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta", + Description: "Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata", + }, + "binaryData": { + Type: "map", + SubType: "string", + Description: "BinaryData contains the binary data. Each key must consist of alphanumeric characters, '-', '_' or '.'. BinaryData can contain byte sequences that are not in the UTF-8 range. The keys stored in BinaryData must not overlap with the ones in the Data field, this is enforced during validation process. Using this field will require 1.10+ apiserver and kubelet.", + }, + "data": { + Type: "map", + SubType: "string", + Description: "Data contains the configuration data. Each key must consist of alphanumeric characters, '-', '_' or '.'. Values with non-UTF-8 byte sequences must use the BinaryData field. The keys stored in Data must not overlap with the keys in the BinaryData field, this is enforced during validation process.", + }, + "immutable": { + Type: "boolean", + Description: "Immutable, if set to true, ensures that data stored in the ConfigMap cannot be updated (only object metadata can be modified). If not set to true, the field can be modified at any time. Defaulted to nil.", + }, + }, + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta": { + ResourceFields: map[string]definitionField{ + "annotations": { + Type: "map", + SubType: "string", + Description: "annotations of the resource", + }, + "name": { + Type: "string", + SubType: "", + Description: "name of the resource", + }, + }, + Type: "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta", + Description: "Object Metadata", + }, + }, + }, + }, + }, + { + // Nullable has fields that are not representable in OpenAPI V2 + // and requires the CRD information to be merged + name: "nullable", + schemaName: "management.cattle.io.nullable", + wantObject: &types.APIObject{ + ID: "management.cattle.io.nullable", + Type: "schemaDefinition", + Object: schemaDefinition{ + DefinitionType: "io.cattle.management.v2.Nullable", + Definitions: map[string]definition{ + "io.cattle.management.v2.Nullable": { + Type: "io.cattle.management.v2.Nullable", + Description: "", + ResourceFields: map[string]definitionField{ + "apiVersion": { + Type: "string", + Description: "The APIVersion of this resource", + }, + "kind": { + Type: "string", + Description: "The kind", + }, + "metadata": { + Type: "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta", + Description: "The metadata", + }, + "spec": { + Type: "io.cattle.management.v2.Nullable.spec", + }, + }, + }, + "io.cattle.management.v2.Nullable.spec": { + Type: "io.cattle.management.v2.Nullable.spec", + Description: "", + ResourceFields: map[string]definitionField{ + "rkeConfig": { + Type: "io.cattle.management.v2.Nullable.spec.rkeConfig", + }, + }, + }, + "io.cattle.management.v2.Nullable.spec.rkeConfig": { + Type: "io.cattle.management.v2.Nullable.spec.rkeConfig", + Description: "", + ResourceFields: map[string]definitionField{ + "additionalManifest": { + Type: "string", + }, + }, + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta": { + ResourceFields: map[string]definitionField{ + "annotations": { + Type: "map", + SubType: "string", + Description: "annotations of the resource", + }, + "name": { + Type: "string", + SubType: "", + Description: "name of the resource", + }, + }, + Type: "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta", + Description: "Object Metadata", + }, + }, + }, + }, + }, + { + // UserAttribute is a CRD, but is not a Kind because the CRD misses some + // fields for the object. + // We still want it to be defined correctly and have default values applied (apiVersion, kind, metadata) + name: "user attribute", + schemaName: "management.cattle.io.userattribute", + wantObject: &types.APIObject{ + ID: "management.cattle.io.userattribute", + Type: "schemaDefinition", + Object: schemaDefinition{ + DefinitionType: "io.cattle.management.v2.UserAttribute", + Definitions: map[string]definition{ + "io.cattle.management.v2.UserAttribute": { + Type: "io.cattle.management.v2.UserAttribute", + Description: "", + ResourceFields: map[string]definitionField{ + "apiVersion": { + Type: "string", + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + }, + "kind": { + Type: "string", + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + }, + "metadata": { + Type: "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta", + Description: "Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata", + }, + }, + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta": { + ResourceFields: map[string]definitionField{ + "annotations": { + Type: "map", + SubType: "string", + Description: "annotations of the resource", + }, + "name": { + Type: "string", + SubType: "", + Description: "name of the resource", + }, + }, + Type: "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta", + Description: "Object Metadata", + }, + }, + }, + }, + }, + { + name: "global role definition", + schemaName: "management.cattle.io.globalrole", + wantObject: &types.APIObject{ + ID: "management.cattle.io.globalrole", + Type: "schemaDefinition", + Object: schemaDefinition{ + DefinitionType: "io.cattle.management.v2.GlobalRole", + Definitions: map[string]definition{ + "io.cattle.management.v2.GlobalRole": { + ResourceFields: map[string]definitionField{ + "apiVersion": { + Type: "string", + Description: "The APIVersion of this resource", + }, + "kind": { + Type: "string", + Description: "The kind", + }, + "metadata": { + Type: "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta", + Description: "The metadata", + }, + "spec": { + Type: "io.cattle.management.v2.GlobalRole.spec", Description: "The spec for the project", + }, + }, + Type: "io.cattle.management.v2.GlobalRole", + Description: "A Global Role V2 provides Global Permissions in Rancher", + }, + "io.cattle.management.v2.GlobalRole.spec": { + ResourceFields: map[string]definitionField{ + "clusterName": { + Type: "string", + Description: "The name of the cluster", + Required: true, + }, + "displayName": { + Type: "string", + Description: "The UI readable name", + Required: true, + }, + "newField": { + Type: "string", + Description: "A new field not present in v1", + }, + "notRequired": { + Type: "boolean", + Description: "Some field that isn't required", + }, + }, + Type: "io.cattle.management.v2.GlobalRole.spec", + Description: "The spec for the project", + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta": { + ResourceFields: map[string]definitionField{ + "annotations": { + Type: "map", + SubType: "string", + Description: "annotations of the resource", + }, + "name": { + Type: "string", + SubType: "", + Description: "name of the resource", + }, + }, + Type: "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta", + Description: "Object Metadata", + }, + }, + }, + }, + }, + { + // The preferred group for management.cattle.io is V2, but DeprecatedResource doesn't + // exist in V2. Steve should be able to fallback to another version (V1). + name: "deprecated resource", + schemaName: "management.cattle.io.deprecatedresource", + wantObject: &types.APIObject{ + ID: "management.cattle.io.deprecatedresource", + Type: "schemaDefinition", + Object: schemaDefinition{ + DefinitionType: "io.cattle.management.v1.DeprecatedResource", + Definitions: map[string]definition{ + "io.cattle.management.v1.DeprecatedResource": { + Type: "io.cattle.management.v1.DeprecatedResource", + Description: "A resource that is not present in v2", + ResourceFields: map[string]definitionField{ + "apiVersion": { + Type: "string", + Description: "The APIVersion of this resource", + }, + "kind": { + Type: "string", + Description: "The kind", + }, + "metadata": { + Type: "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta", + Description: "The metadata", + }, + }, + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta": { + ResourceFields: map[string]definitionField{ + "annotations": { + Type: "map", + SubType: "string", + Description: "annotations of the resource", + }, + "name": { + Type: "string", + SubType: "", + Description: "name of the resource", + }, + }, + Type: "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta", + Description: "Object Metadata", + }, + }, + }, + }, + }, + { + name: "baseSchema", + schemaName: "builtin", + wantObject: &types.APIObject{ + ID: "builtin", + Type: "schemaDefinition", + Object: schemaDefinition{ + DefinitionType: "builtin", + Definitions: map[string]definition{ + "builtin": { + ResourceFields: map[string]definitionField{ + "complex": { + Type: "map", + SubType: "string", + Description: "some complex field", + }, + "complexArray": { + Type: "array", + SubType: "string", + Description: "some complex array field", + }, + "complexRef": { + Type: "reference", + SubType: "complex", + Description: "some complex reference field", + }, + "simple": { + Type: "string", + Description: "some simple field", + Required: true, + }, + "leftBracket": { + Type: "test[", + Description: "some field with a open bracket but no close bracket", + }, + }, + Type: "builtin", + Description: "some builtin type", + }, + }, + }, + }, + }, + { + name: "not a kind", + schemaName: "management.cattle.io.notakind", + wantError: true, + wantErrorCode: intPtr(503), + }, + { + name: "missing definition", + schemaName: "management.cattle.io.cluster", + wantError: true, + wantErrorCode: intPtr(404), + }, + { + name: "not refreshed", + schemaName: "management.cattle.io.globalrole", + skipRefresh: true, + wantError: true, + wantErrorCode: intPtr(503), + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + crdCache := fake.NewMockNonNamespacedCacheInterface[*apiextv1.CustomResourceDefinition](ctrl) + crds, err := getCRDs() + require.NoError(t, err) + + crdCache.EXPECT().List(labels.Everything()).Return(crds, nil).AnyTimes() + + handler := NewSchemaDefinitionHandler(baseSchemas, crdCache, discoveryClient) + if !test.skipRefresh { + err = handler.Refresh() + require.NoError(t, err) + } + request := types.APIRequest{ + Schemas: schemas, + Name: test.schemaName, + } + response, err := handler.byIDHandler(&request) + if test.wantError { + require.Error(t, err) + if test.wantErrorCode != nil { + require.True(t, apierror.IsAPIError(err)) + apiErr, _ := err.(*apierror.APIError) + require.Equal(t, *test.wantErrorCode, apiErr.Code.Status) + } + } else { + require.NoError(t, err) + require.Equal(t, *test.wantObject, response) + } + }) + } +} + +func buildDefaultDiscovery() (*fakeDiscovery, error) { + document, err := openapi_v2.ParseDocument([]byte(openapi_raw)) + if err != nil { + return nil, fmt.Errorf("unable to parse openapi document %w", err) + } + groups := []metav1.APIGroup{ + // The core groups (eg: Pods, ConfigMaps, etc) + { + Name: "", + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: "v1", + Version: "v1", + }, + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "v1", + Version: "v1", + }, + }, + }, + { + Name: "management.cattle.io", + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: "management.cattle.io/v2", + Version: "v2", + }, + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "management.cattle.io/v1", + Version: "v1", + }, + { + GroupVersion: "management.cattle.io/v2", + Version: "v2", + }, + }, + }, + { + Name: "noversion.cattle.io", + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "noversion.cattle.io/v1", + Version: "v1", + }, + { + GroupVersion: "noversion.cattle.io/v2", + Version: "v2", + }, + }, + }, + } + return &fakeDiscovery{ + Groups: &metav1.APIGroupList{ + Groups: groups, + }, + Document: document, + }, nil +} + +type fakeDiscovery struct { + Groups *metav1.APIGroupList + Document *openapi_v2.Document + GroupsErr error + DocumentErr error +} + +// ServerGroups is the only method that needs to be mocked +func (f *fakeDiscovery) ServerGroups() (*metav1.APIGroupList, error) { + return f.Groups, f.GroupsErr +} + +// The rest of these methods are just here to conform to discovery.DiscoveryInterface +func (f *fakeDiscovery) ServerGroupsAndResources() ([]*metav1.APIGroup, []*metav1.APIResourceList, error) { + return nil, nil, nil +} + +func (f *fakeDiscovery) RESTClient() restclient.Interface { return nil } +func (f *fakeDiscovery) ServerResourcesForGroupVersion(groupVersion string) (*metav1.APIResourceList, error) { + return nil, nil +} +func (f *fakeDiscovery) ServerPreferredResources() ([]*metav1.APIResourceList, error) { + return nil, nil +} +func (f *fakeDiscovery) ServerPreferredNamespacedResources() ([]*metav1.APIResourceList, error) { + return nil, nil +} +func (f *fakeDiscovery) ServerVersion() (*version.Info, error) { return nil, nil } +func (f *fakeDiscovery) OpenAPISchema() (*openapi_v2.Document, error) { + return f.Document, f.DocumentErr +} +func (f *fakeDiscovery) OpenAPIV3() openapi.Client { return nil } +func (f *fakeDiscovery) WithLegacy() discovery.DiscoveryInterface { return f } + +func getJSONSchema(crds []*apiextv1.CustomResourceDefinition, name, version string) *apiextv1.JSONSchemaProps { + for _, crd := range crds { + if crd.GetName() != name { + continue + } + + for _, ver := range crd.Spec.Versions { + if ver.Name != version { + continue + } + + return ver.Schema.OpenAPIV3Schema + } + } + return nil +} diff --git a/pkg/schema/definitions/refresh.go b/pkg/schema/definitions/refresh.go new file mode 100644 index 00000000..394873cb --- /dev/null +++ b/pkg/schema/definitions/refresh.go @@ -0,0 +1,48 @@ +package definitions + +import ( + "context" + "time" + + "github.com/rancher/steve/pkg/debounce" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiregv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1" +) + +// refreshHandler triggers refreshes for a Debounceable refresher after a CRD/APIService has been changed +// intended to refresh the schema definitions after a CRD has been added and is hopefully available in k8s. +type refreshHandler struct { + // debounceRef is the debounceableRefresher containing the Refreshable (typically the schema definition handler) + debounceRef *debounce.DebounceableRefresher + // debounceDuration is the duration that the handler should ask the DebounceableRefresher to wait before refreshing + debounceDuration time.Duration +} + +// onChangeCRD refreshes the debounceRef after a CRD is added/changed +func (r *refreshHandler) onChangeCRD(key string, crd *apiextv1.CustomResourceDefinition) (*apiextv1.CustomResourceDefinition, error) { + r.debounceRef.RefreshAfter(r.debounceDuration) + return crd, nil +} + +// onChangeAPIService refreshes the debounceRef after an APIService is added/changed +func (r *refreshHandler) onChangeAPIService(key string, api *apiregv1.APIService) (*apiregv1.APIService, error) { + r.debounceRef.RefreshAfter(r.debounceDuration) + return api, nil +} + +// startBackgroundRefresh starts a force refresh that runs for every tick of duration. Can be stopped +// by cancelling the context +func (r *refreshHandler) startBackgroundRefresh(ctx context.Context, duration time.Duration) { + go func() { + ticker := time.NewTicker(duration) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + r.debounceRef.RefreshAfter(r.debounceDuration) + } + } + }() +} diff --git a/pkg/schema/definitions/refresh_test.go b/pkg/schema/definitions/refresh_test.go new file mode 100644 index 00000000..ed5e44d6 --- /dev/null +++ b/pkg/schema/definitions/refresh_test.go @@ -0,0 +1,113 @@ +package definitions + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/rancher/steve/pkg/debounce" + "github.com/stretchr/testify/require" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apiregv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1" +) + +type refreshable struct { + refreshChannel chan struct{} +} + +func (r *refreshable) Refresh() error { + r.refreshChannel <- struct{}{} + return nil +} + +func Test_onChangeCRD(t *testing.T) { + t.Parallel() + refreshChannel := make(chan struct{}, 1) + defer close(refreshChannel) + internalRefresh := refreshable{ + refreshChannel: refreshChannel, + } + refresher := debounce.DebounceableRefresher{ + Refreshable: &internalRefresh, + } + refreshHandler := refreshHandler{ + debounceRef: &refresher, + debounceDuration: time.Microsecond * 2, + } + input := apiextv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-crd", + }, + } + output, err := refreshHandler.onChangeCRD("test-crd", &input) + require.Nil(t, err) + require.Equal(t, input, *output) + err = receiveWithTimeout(refreshChannel, time.Second*5) + require.NoError(t, err) +} + +func Test_onChangeAPIService(t *testing.T) { + t.Parallel() + refreshChannel := make(chan struct{}, 1) + defer close(refreshChannel) + internalRefresh := refreshable{ + refreshChannel: refreshChannel, + } + refresher := debounce.DebounceableRefresher{ + Refreshable: &internalRefresh, + } + refreshHandler := refreshHandler{ + debounceRef: &refresher, + debounceDuration: time.Microsecond * 2, + } + input := apiregv1.APIService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-apiservice", + }, + } + output, err := refreshHandler.onChangeAPIService("test-apiservice", &input) + require.Nil(t, err) + require.Equal(t, input, *output) + err = receiveWithTimeout(refreshChannel, time.Second*5) + require.NoError(t, err) + +} + +func Test_startBackgroundRefresh(t *testing.T) { + t.Parallel() + refreshChannel := make(chan struct{}, 1) + internalRefresh := refreshable{ + refreshChannel: refreshChannel, + } + refresher := debounce.DebounceableRefresher{ + Refreshable: &internalRefresh, + } + refreshHandler := refreshHandler{ + debounceRef: &refresher, + debounceDuration: time.Microsecond * 2, + } + ctx, cancel := context.WithCancel(context.Background()) + refreshHandler.startBackgroundRefresh(ctx, time.Microsecond*2) + + err := receiveWithTimeout(refreshChannel, time.Second*5) + require.NoError(t, err) + // we want to stop the refresher before closing the channel to avoid errors + // since this just stops the background refresh from asking for a new refresh, we still + // need to wait for any currently debounced refreshes to finish + cancel() + time.Sleep(time.Second * 1) + close(refreshChannel) +} + +func receiveWithTimeout(channel chan struct{}, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + select { + case <-channel: + return nil + case <-ctx.Done(): + return fmt.Errorf("channel did not recieve value in timeout %d", timeout) + } +} diff --git a/pkg/schema/definitions/schema.go b/pkg/schema/definitions/schema.go new file mode 100644 index 00000000..548f187a --- /dev/null +++ b/pkg/schema/definitions/schema.go @@ -0,0 +1,118 @@ +package definitions + +import ( + "context" + "fmt" + "os" + "strconv" + "time" + + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/steve/pkg/debounce" + apiextcontrollerv1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/apiextensions.k8s.io/v1" + v1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/apiregistration.k8s.io/v1" + "github.com/rancher/wrangler/v3/pkg/schemas" + "github.com/sirupsen/logrus" + "k8s.io/client-go/discovery" +) + +const ( + handlerKey = "schema-definitions" + delayEnvVar = "CATTLE_CRD_REFRESH_DELAY_SECONDS" + defaultDelay = 2 + delayUnit = time.Second + refreshEnvVar = "CATTLE_BACKGROUND_REFRESH_MINUTES" + defaultRefresh = 10 + refreshUnit = time.Minute +) + +type schemaDefinition struct { + DefinitionType string `json:"definitionType"` + Definitions map[string]definition `json:"definitions"` +} + +type definition struct { + ResourceFields map[string]definitionField `json:"resourceFields"` + Type string `json:"type"` + Description string `json:"description"` +} + +type definitionField struct { + Type string `json:"type"` + SubType string `json:"subtype,omitempty"` + Description string `json:"description,omitempty"` + Required bool `json:"required,omitempty"` +} + +// Merge merges the provided schema into s. All conflicting values (i.e. that are in both schema and s) +// are replaced with the values from s. +func (s *schemaDefinition) Merge(schema schemaDefinition) error { + if s.DefinitionType != schema.DefinitionType { + return fmt.Errorf("invalid definition type: %s != %s", s.DefinitionType, schema.DefinitionType) + } + + for key, value := range schema.Definitions { + mergedDef := s.Definitions[key] + if mergedDef.ResourceFields == nil { + mergedDef.ResourceFields = make(map[string]definitionField) + } + + mergedDef.Type = value.Type + mergedDef.Description = value.Description + for fieldKey, fieldValue := range value.ResourceFields { + mergedDef.ResourceFields[fieldKey] = fieldValue + } + s.Definitions[key] = mergedDef + } + return nil +} + +// Register registers the schemaDefinition schema. +func Register(ctx context.Context, + baseSchema *types.APISchemas, + client discovery.DiscoveryInterface, + crd apiextcontrollerv1.CustomResourceDefinitionController, + apiService v1.APIServiceController) { + handler := NewSchemaDefinitionHandler(baseSchema, crd.Cache(), client) + baseSchema.MustAddSchema(types.APISchema{ + Schema: &schemas.Schema{ + ID: "schemaDefinition", + PluralName: "schemaDefinitions", + ResourceMethods: []string{"GET"}, + }, + ByIDHandler: handler.byIDHandler, + }) + + debounce := debounce.DebounceableRefresher{ + Refreshable: handler, + } + crdDebounce := getDurationEnvVarOrDefault(delayEnvVar, defaultDelay, delayUnit) + refHandler := refreshHandler{ + debounceRef: &debounce, + debounceDuration: crdDebounce, + } + crd.OnChange(ctx, handlerKey, refHandler.onChangeCRD) + apiService.OnChange(ctx, handlerKey, refHandler.onChangeAPIService) + refreshFrequency := getDurationEnvVarOrDefault(refreshEnvVar, defaultRefresh, refreshUnit) + // there's a delay between when a CRD is created and when it is available in the openapi/v2 endpoint + // the crd/apiservice controllers use a delay of 2 seconds to account for this, but it's possible that this isn't + // enough in certain environments, so we also use an infrequent background refresh to eventually correct any misses + refHandler.startBackgroundRefresh(ctx, refreshFrequency) +} + +// getDurationEnvVarOrDefault gets the duration value for a given envVar. If not found, it returns the provided default. +// unit is the unit of time (time.Second/time.Minute/etc.) that the returned duration should be in +func getDurationEnvVarOrDefault(envVar string, defaultVal int, unit time.Duration) time.Duration { + defaultDuration := time.Duration(defaultVal) * unit + envValue, ok := os.LookupEnv(envVar) + if !ok { + return defaultDuration + } + parsed, err := strconv.Atoi(envValue) + if err != nil { + logrus.Errorf("Env var %s was specified, but could not be converted to an int, default of %d seconds will be used", + envVar, int64(defaultDuration.Seconds())) + return defaultDuration + } + return time.Duration(parsed) * unit +} diff --git a/pkg/schema/definitions/schema_test.go b/pkg/schema/definitions/schema_test.go new file mode 100644 index 00000000..9af3f78e --- /dev/null +++ b/pkg/schema/definitions/schema_test.go @@ -0,0 +1,323 @@ +package definitions + +import ( + "context" + "os" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/wrangler/v3/pkg/generic/fake" + "github.com/stretchr/testify/require" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiregv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1" +) + +func TestRegister(t *testing.T) { + schemas := types.EmptyAPISchemas() + client := fakeDiscovery{} + ctrl := gomock.NewController(t) + crdController := fake.NewMockNonNamespacedControllerInterface[*apiextv1.CustomResourceDefinition, *apiextv1.CustomResourceDefinitionList](ctrl) + apisvcController := fake.NewMockNonNamespacedControllerInterface[*apiregv1.APIService, *apiregv1.APIServiceList](ctrl) + ctx, cancel := context.WithCancel(context.Background()) + crdController.EXPECT().OnChange(ctx, handlerKey, gomock.Any()) + crdController.EXPECT().Cache().AnyTimes() + apisvcController.EXPECT().OnChange(ctx, handlerKey, gomock.Any()) + Register(ctx, schemas, &client, crdController, apisvcController) + registeredSchema := schemas.LookupSchema("schemaDefinition") + require.NotNil(t, registeredSchema) + require.Len(t, registeredSchema.ResourceMethods, 1) + require.Equal(t, registeredSchema.ResourceMethods[0], "GET") + require.NotNil(t, registeredSchema.ByIDHandler) + // Register will spawn a background thread, so we want to stop that to not impact other tests + cancel() +} + +func Test_getDurationEnvVarOrDefault(t *testing.T) { + os.Setenv("VALID", "1") + os.Setenv("INVALID", "NOTANUMBER") + tests := []struct { + name string + envVar string + defaultValue int + unit time.Duration + wantDuration time.Duration + }{ + { + name: "not found, use default", + envVar: "NOT_FOUND", + defaultValue: 12, + unit: time.Second, + wantDuration: time.Second * 12, + }, + { + name: "found but not an int", + envVar: "INVALID", + defaultValue: 24, + unit: time.Minute, + wantDuration: time.Minute * 24, + }, + { + name: "found and valid int", + envVar: "VALID", + defaultValue: 30, + unit: time.Hour, + wantDuration: time.Hour * 1, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + got := getDurationEnvVarOrDefault(test.envVar, test.defaultValue, test.unit) + require.Equal(t, test.wantDuration, got) + }) + } +} + +func TestSchemaDefinitionMerge(t *testing.T) { + tests := []struct { + name string + schemas [2]schemaDefinition + wantErr bool + expected schemaDefinition + }{ + { + name: "merge top-level definitions", + schemas: [2]schemaDefinition{ + { + DefinitionType: "foo", + Definitions: map[string]definition{ + "foo": { + Type: "foo", + Description: "Foo", + }, + }, + }, + { + DefinitionType: "foo", + Definitions: map[string]definition{ + "bar": { + Type: "bar", + Description: "Bar", + ResourceFields: map[string]definitionField{}, + }, + }, + }, + }, + expected: schemaDefinition{ + DefinitionType: "foo", + Definitions: map[string]definition{ + "foo": { + Type: "foo", + Description: "Foo", + }, + "bar": { + Type: "bar", + Description: "Bar", + ResourceFields: map[string]definitionField{}, + }, + }, + }, + }, + { + name: "merge resource fields", + schemas: [2]schemaDefinition{ + { + DefinitionType: "foo", + Definitions: map[string]definition{ + "foo": { + Type: "foo", + Description: "Foo", + ResourceFields: map[string]definitionField{ + "old": { + Type: "string", + Description: "foo.old", + }, + "inBoth": { + Type: "string", + Description: "foo.inBoth", + }, + }, + }, + }, + }, + { + DefinitionType: "foo", + Definitions: map[string]definition{ + "foo": { + Type: "foo", + Description: "Foo", + ResourceFields: map[string]definitionField{ + "new": { + Type: "string", + Description: "foo.new", + }, + "inBoth": { + Type: "array", + SubType: "number", + Description: "foo.inBoth", + Required: true, + }, + }, + }, + }, + }, + }, + expected: schemaDefinition{ + DefinitionType: "foo", + Definitions: map[string]definition{ + "foo": { + Type: "foo", + Description: "Foo", + ResourceFields: map[string]definitionField{ + "new": { + Type: "string", + Description: "foo.new", + }, + "old": { + Type: "string", + Description: "foo.old", + }, + "inBoth": { + Type: "array", + SubType: "number", + Description: "foo.inBoth", + Required: true, + }, + }, + }, + }, + }, + }, + { + name: "empty resource fields in old", + schemas: [2]schemaDefinition{ + { + DefinitionType: "foo", + Definitions: map[string]definition{ + "foo": { + Type: "foo", + Description: "Foo", + }, + }, + }, + { + DefinitionType: "foo", + Definitions: map[string]definition{ + "foo": { + Type: "foo", + Description: "Foo", + ResourceFields: map[string]definitionField{ + "new": { + Type: "string", + Description: "foo.new", + }, + }, + }, + }, + }, + }, + expected: schemaDefinition{ + DefinitionType: "foo", + Definitions: map[string]definition{ + "foo": { + Type: "foo", + Description: "Foo", + ResourceFields: map[string]definitionField{ + "new": { + Type: "string", + Description: "foo.new", + }, + }, + }, + }, + }, + }, + { + name: "empty resource fields in new", + schemas: [2]schemaDefinition{ + { + DefinitionType: "foo", + Definitions: map[string]definition{ + "foo": { + Type: "string", + Description: "Foo", + ResourceFields: map[string]definitionField{ + "old": { + Type: "string", + Description: "foo.old", + }, + }, + }, + }, + }, + { + DefinitionType: "foo", + Definitions: map[string]definition{ + "foo": { + Type: "string", + Description: "Foo", + }, + }, + }, + }, + expected: schemaDefinition{ + DefinitionType: "foo", + Definitions: map[string]definition{ + "foo": { + Type: "string", + Description: "Foo", + ResourceFields: map[string]definitionField{ + "old": { + Type: "string", + Description: "foo.old", + }, + }, + }, + }, + }, + }, + { + name: "empty definition type", + schemas: [2]schemaDefinition{ + { + DefinitionType: "foo", + Definitions: map[string]definition{ + "foo": { + Type: "foo", + Description: "Foo", + ResourceFields: map[string]definitionField{ + "old": { + Type: "string", + Description: "foo.old", + }, + }, + }, + }, + }, + { + DefinitionType: "", + Definitions: map[string]definition{}, + }, + }, + wantErr: true, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := test.schemas[0].Merge(test.schemas[1]) + if test.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, test.schemas[0]) + } + + }) + } +} diff --git a/pkg/schema/definitions/visitor.go b/pkg/schema/definitions/visitor.go new file mode 100644 index 00000000..518bfb07 --- /dev/null +++ b/pkg/schema/definitions/visitor.go @@ -0,0 +1,117 @@ +package definitions + +import ( + "k8s.io/kube-openapi/pkg/util/proto" +) + +// schemaFieldVisitor implements proto.SchemaVisitor and turns a given schema into a definitionField. +type schemaFieldVisitor struct { + field definitionField + definitions map[string]definition +} + +// VisitArray turns an array into a definitionField (stored on the receiver). For arrays of complex types, will also +// visit the subtype. +func (s *schemaFieldVisitor) VisitArray(array *proto.Array) { + field := definitionField{ + Description: array.GetDescription(), + } + // this currently is not recursive and provides little information for nested types- while this isn't optimal, + // it was kept this way to provide backwards compat with previous endpoints. + array.SubType.Accept(s) + subField := s.field + field.Type = "array" + field.SubType = subField.Type + s.field = field +} + +// VisitMap turns a map into a definitionField (stored on the receiver). For maps of complex types, will also visit the +// subtype. +func (s *schemaFieldVisitor) VisitMap(protoMap *proto.Map) { + field := definitionField{ + Description: protoMap.GetDescription(), + } + // this currently is not recursive and provides little information for nested types- while this isn't optimal, + // it was kept this way to provide backwards compat with previous endpoints. + protoMap.SubType.Accept(s) + subField := s.field + field.Type = "map" + field.SubType = subField.Type + s.field = field +} + +// VisitPrimitive turns a primitive into a definitionField (stored on the receiver). +func (s *schemaFieldVisitor) VisitPrimitive(primitive *proto.Primitive) { + field := definitionField{ + Description: primitive.GetDescription(), + } + field.Type = getPrimitiveType(primitive.Type) + s.field = field +} + +// VisitKind turns a kind into a definitionField and a definition. Both are stored on the receiver. +func (s *schemaFieldVisitor) VisitKind(kind *proto.Kind) { + path := kind.Path.String() + field := definitionField{ + Description: kind.GetDescription(), + Type: path, + } + if _, ok := s.definitions[path]; ok { + // if we have already seen this kind, we don't want to re-evaluate the definition. Some kinds can be + // recursive through use of references, so this circuit-break is necessary to avoid infinite loops + s.field = field + return + } + schemaDefinition := definition{ + ResourceFields: map[string]definitionField{}, + Type: path, + Description: kind.GetDescription(), + } + // this definition may refer to itself, so we mark this as seen to not infinitely recurse + s.definitions[path] = definition{} + for fieldName, schemaField := range kind.Fields { + schemaField.Accept(s) + schemaDefinition.ResourceFields[fieldName] = s.field + } + for _, field := range kind.RequiredFields { + current, ok := schemaDefinition.ResourceFields[field] + if !ok { + // this does silently ignore inconsistent kinds that list + continue + } + current.Required = true + schemaDefinition.ResourceFields[field] = current + } + s.definitions[path] = schemaDefinition + // the visitor may have set the field multiple times while evaluating kind fields, so we only set the final + // kind-based field at the end + s.field = field +} + +// VisitReference turns a reference into a definitionField. Will also visit the referred type. +func (s *schemaFieldVisitor) VisitReference(ref proto.Reference) { + sub := ref.SubSchema() + if sub == nil { + // if we don't have a sub-schema defined, we can't extract much meaningful information + field := definitionField{ + Description: ref.GetDescription(), + Type: ref.Reference(), + } + s.field = field + return + } + sub.Accept(s) + field := s.field + field.Description = ref.GetDescription() + s.field = field +} + +// VisitArbitrary turns an abitrary (item with no type) into a definitionField (stored on the receiver). +func (s *schemaFieldVisitor) VisitArbitrary(arb *proto.Arbitrary) { + // In certain cases k8s seems to not provide a type for certain fields. We assume for the + // purposes of this visitor that all of these have a type of string. + s.field = definitionField{ + Description: arb.GetDescription(), + Type: "string", + } +} diff --git a/pkg/schema/definitions/visitor_test.go b/pkg/schema/definitions/visitor_test.go new file mode 100644 index 00000000..c9497d4f --- /dev/null +++ b/pkg/schema/definitions/visitor_test.go @@ -0,0 +1,235 @@ +package definitions + +import ( + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/kube-openapi/pkg/util/proto" +) + +var ( + protoPrimitive = proto.Primitive{ + BaseSchema: proto.BaseSchema{ + Description: "primitive value", + }, + Type: "string", + } + protoPrimitiveInt = proto.Primitive{ + BaseSchema: proto.BaseSchema{ + Description: "primitive value - int", + }, + Type: "integer", + } + protoPrimitiveNumber = proto.Primitive{ + BaseSchema: proto.BaseSchema{ + Description: "primitive value - number", + }, + Type: "number", + } + protoArray = proto.Array{ + BaseSchema: proto.BaseSchema{ + Description: "testArray", + }, + SubType: &protoPrimitive, + } + protoMap = proto.Map{ + BaseSchema: proto.BaseSchema{ + Description: "testMap", + }, + SubType: &protoPrimitive, + } + protoKind = proto.Kind{ + BaseSchema: proto.BaseSchema{ + Description: "testKind", + Path: proto.NewPath("io.cattle.test"), + }, + Fields: map[string]proto.Schema{ + "protoArray": &protoArray, + "protoPrimitive": &protoPrimitive, + "protoMap": &protoMap, + }, + RequiredFields: []string{ + "protoArray", + "protoPrimitive", + "missing", + }, + } + protoRefNoSubSchema = openAPIV2Reference{ + BaseSchema: proto.BaseSchema{ + Description: "testRef - no subSchema", + }, + reference: "some-other-type", + } + protoRef = openAPIV2Reference{ + BaseSchema: proto.BaseSchema{ + Description: "testRef", + }, + reference: "testKind", + subSchema: &protoKind, + } + protoArbitrary = proto.Arbitrary{ + BaseSchema: proto.BaseSchema{ + Description: "testArbitrary", + }, + } +) + +func TestSchemaFieldVisitor(t *testing.T) { + protoKind.Fields["protoRef"] = &protoRef + tests := []struct { + name string + inputSchema proto.Schema + wantDefinitions map[string]definition + wantField definitionField + }{ + { + name: "array", + inputSchema: &protoArray, + wantDefinitions: map[string]definition{}, + wantField: definitionField{ + Type: "array", + Description: protoArray.Description, + SubType: protoPrimitive.Type, + }, + }, + { + name: "map", + inputSchema: &protoMap, + wantDefinitions: map[string]definition{}, + wantField: definitionField{ + Type: "map", + Description: protoMap.Description, + SubType: protoPrimitive.Type, + }, + }, + { + name: "string primitive", + inputSchema: &protoPrimitive, + wantDefinitions: map[string]definition{}, + wantField: definitionField{ + Type: protoPrimitive.Type, + Description: protoPrimitive.Description, + }, + }, + { + name: "integer primitive", + inputSchema: &protoPrimitiveInt, + wantDefinitions: map[string]definition{}, + wantField: definitionField{ + Type: "int", + Description: protoPrimitiveInt.Description, + }, + }, + { + name: "number primitive", + inputSchema: &protoPrimitiveNumber, + wantDefinitions: map[string]definition{}, + wantField: definitionField{ + Type: "int", + Description: protoPrimitiveNumber.Description, + }, + }, + { + name: "kind", + inputSchema: &protoKind, + wantDefinitions: map[string]definition{ + protoKind.Path.String(): { + ResourceFields: map[string]definitionField{ + "protoArray": { + Type: "array", + Description: protoArray.Description, + SubType: protoPrimitive.Type, + Required: true, + }, + "protoMap": { + Type: "map", + Description: protoMap.Description, + SubType: protoPrimitive.Type, + }, + "protoPrimitive": { + Type: protoPrimitive.Type, + Description: protoPrimitive.Description, + Required: true, + }, + "protoRef": { + Type: protoKind.Path.String(), + Description: protoRef.Description, + }, + }, + Type: protoKind.Path.String(), + Description: protoKind.Description, + }, + }, + wantField: definitionField{ + Description: protoKind.Description, + Type: protoKind.Path.String(), + }, + }, + { + name: "reference no subschema", + inputSchema: &protoRefNoSubSchema, + wantDefinitions: map[string]definition{}, + wantField: definitionField{ + Type: protoRefNoSubSchema.reference, + Description: protoRefNoSubSchema.Description, + }, + }, + { + name: "reference", + inputSchema: &protoRef, + wantDefinitions: map[string]definition{ + protoKind.Path.String(): { + ResourceFields: map[string]definitionField{ + "protoArray": { + Type: "array", + Description: protoArray.Description, + SubType: protoPrimitive.Type, + Required: true, + }, + "protoMap": { + Type: "map", + Description: protoMap.Description, + SubType: protoPrimitive.Type, + }, + "protoPrimitive": { + Type: protoPrimitive.Type, + Description: protoPrimitive.Description, + Required: true, + }, + "protoRef": { + Type: protoKind.Path.String(), + Description: protoRef.Description, + }, + }, + Type: protoKind.Path.String(), + Description: protoKind.Description, + }, + }, + wantField: definitionField{ + Type: protoKind.Path.String(), + Description: protoRef.Description, + }, + }, + { + name: "abitrary schema", + inputSchema: &protoArbitrary, + wantDefinitions: map[string]definition{}, + wantField: definitionField{ + Type: "string", + Description: protoArbitrary.Description, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + definitions := map[string]definition{} + visitor := schemaFieldVisitor{ + definitions: definitions, + } + test.inputSchema.Accept(&visitor) + require.Equal(t, test.wantField, visitor.field) + require.Equal(t, test.wantDefinitions, visitor.definitions) + }) + } +} diff --git a/pkg/schema/factory.go b/pkg/schema/factory.go index b16af184..b3fbe4a4 100644 --- a/pkg/schema/factory.go +++ b/pkg/schema/factory.go @@ -1,6 +1,8 @@ package schema +//go:generate mockgen --build_flags=--mod=mod -package fake -destination fake/factory.go "github.com/rancher/steve/pkg/schema" Factory import ( + "context" "fmt" "net/http" "time" @@ -9,9 +11,18 @@ import ( "github.com/rancher/apiserver/pkg/types" "github.com/rancher/steve/pkg/accesscontrol" "github.com/rancher/steve/pkg/attributes" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/authentication/user" ) +type Factory interface { + Schemas(user user.Info) (*types.APISchemas, error) + ByGVR(gvr schema.GroupVersionResource) string + ByGVK(gvr schema.GroupVersionKind) string + OnChange(ctx context.Context, cb func()) + AddTemplate(template ...Template) +} + func newSchemas() (*types.APISchemas, error) { apiSchemas := types.EmptyAPISchemas() if err := apiSchemas.AddSchemas(builtin.Schemas); err != nil { @@ -23,6 +34,7 @@ func newSchemas() (*types.APISchemas, error) { func (c *Collection) Schemas(user user.Info) (*types.APISchemas, error) { access := c.as.AccessFor(user) + c.removeOldRecords(access, user) val, ok := c.cache.Get(access.ID) if ok { schemas, _ := val.(*types.APISchemas) @@ -33,9 +45,32 @@ func (c *Collection) Schemas(user user.Info) (*types.APISchemas, error) { if err != nil { return nil, err } + c.addToCache(access, user, schemas) + return schemas, nil +} + +func (c *Collection) removeOldRecords(access *accesscontrol.AccessSet, user user.Info) { + current, ok := c.userCache.Get(user.GetName()) + if ok { + currentID, cOk := current.(string) + if cOk && currentID != access.ID { + // we only want to keep around one record per user. If our current access record is invalid, purge the + //record of it from the cache, so we don't keep duplicates + c.purgeUserRecords(currentID) + c.userCache.Remove(user.GetName()) + } + } +} +func (c *Collection) addToCache(access *accesscontrol.AccessSet, user user.Info, schemas *types.APISchemas) { c.cache.Add(access.ID, schemas, 24*time.Hour) - return schemas, nil + c.userCache.Add(user.GetName(), access.ID, 24*time.Hour) +} + +// PurgeUserRecords removes a record from the backing LRU cache before expiry +func (c *Collection) purgeUserRecords(id string) { + c.cache.Remove(id) + c.as.PurgeUserData(id) } func (c *Collection) schemasForSubject(access *accesscontrol.AccessSet) (*types.APISchemas, error) { diff --git a/pkg/schema/factory_test.go b/pkg/schema/factory_test.go new file mode 100644 index 00000000..44dd0eaa --- /dev/null +++ b/pkg/schema/factory_test.go @@ -0,0 +1,169 @@ +package schema + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/wrangler/v3/pkg/schemas" + k8sSchema "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/authentication/user" +) + +const ( + testGroup = "test.k8s.io" + testVersion = "v1" +) + +type schemaTestConfig struct { + permissionVerbs []string + desiredResourceVerbs []string + desiredCollectionVerbs []string + errDesired bool +} + +func TestSchemas(t *testing.T) { + tests := []struct { + name string + config schemaTestConfig + }{ + { + name: "basic get schema test", + config: schemaTestConfig{ + permissionVerbs: []string{"get"}, + desiredResourceVerbs: []string{"GET"}, + desiredCollectionVerbs: []string{"GET"}, + errDesired: false, + }, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + // test caching functionality + mockLookup := newMockAccessSetLookup() + userName := "testUser" + testUser := user.DefaultInfo{ + Name: userName, + UID: userName, + Groups: []string{}, + Extra: map[string][]string{}, + } + + collection := NewCollection(context.TODO(), types.EmptyAPISchemas(), mockLookup) + collection.schemas = map[string]*types.APISchema{"testCRD": makeSchema("testCRD")} + runSchemaTest(t, test.config, mockLookup, collection, &testUser) + }) + } +} +func TestSchemaCache(t *testing.T) { + // Schemas are a frequently used resource. It's important that the cache doesn't have a leak given size/frequency of resource + tests := []struct { + name string + before schemaTestConfig + after schemaTestConfig + }{ + { + name: "permissions increase, cache size same", + before: schemaTestConfig{ + permissionVerbs: []string{"get"}, + desiredResourceVerbs: []string{"GET"}, + desiredCollectionVerbs: []string{"GET"}, + errDesired: false, + }, + after: schemaTestConfig{ + permissionVerbs: []string{"get", "create", "delete"}, + desiredResourceVerbs: []string{"GET", "DELETE"}, + desiredCollectionVerbs: []string{"GET", "POST"}, + errDesired: false, + }, + }, + { + name: "permissions decrease, cache size same", + before: schemaTestConfig{ + permissionVerbs: []string{"get", "create", "delete"}, + desiredResourceVerbs: []string{"GET", "DELETE"}, + desiredCollectionVerbs: []string{"GET", "POST"}, + errDesired: false, + }, + after: schemaTestConfig{ + permissionVerbs: []string{"get"}, + desiredResourceVerbs: []string{"GET"}, + desiredCollectionVerbs: []string{"GET"}, + errDesired: false, + }, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + // test caching functionality + mockLookup := newMockAccessSetLookup() + userName := "testUser" + testUser := user.DefaultInfo{ + Name: userName, + UID: userName, + Groups: []string{}, + Extra: map[string][]string{}, + } + collection := NewCollection(context.TODO(), types.EmptyAPISchemas(), mockLookup) + collection.schemas = map[string]*types.APISchema{"testCRD": makeSchema("testCRD")} + runSchemaTest(t, test.before, mockLookup, collection, &testUser) + assert.Len(t, collection.cache.Keys(), 1, "expected cache to be size 1") + mockLookup.Clear() + runSchemaTest(t, test.after, mockLookup, collection, &testUser) + assert.Len(t, collection.cache.Keys(), 1, "expected cache to be size 1") + }) + } +} + +func runSchemaTest(t *testing.T, config schemaTestConfig, lookup *mockAccessSetLookup, collection *Collection, testUser user.Info) { + for _, verb := range config.permissionVerbs { + lookup.AddAccessForUser(testUser, verb, k8sSchema.GroupResource{Group: testGroup, Resource: "testCRD"}, "*", "*") + } + + collection.schemas = map[string]*types.APISchema{"testCRD": makeSchema("testCRD")} + userSchemas, err := collection.Schemas(testUser) + if config.errDesired { + assert.Error(t, err, "expected error but none was found") + } + var testSchema *types.APISchema + for schemaName, userSchema := range userSchemas.Schemas { + if schemaName == "testCRD" { + testSchema = userSchema + } + } + assert.NotNil(t, testSchema, "expected a test schema, but was nil") + assert.Len(t, testSchema.ResourceMethods, len(config.desiredResourceVerbs), "did not get as many verbs as expected for resource methods") + assert.Len(t, testSchema.CollectionMethods, len(config.desiredCollectionVerbs), "did not get as many verbs as expected for resource methods") + for _, verb := range config.desiredResourceVerbs { + assert.Contains(t, testSchema.ResourceMethods, verb, "did not find %s in resource methods %v", verb, testSchema.ResourceMethods) + } + for _, verb := range config.desiredCollectionVerbs { + assert.Contains(t, testSchema.CollectionMethods, verb, "did not find %s in resource methods %v", verb, testSchema.CollectionMethods) + } +} + +func makeSchema(resourceType string) *types.APISchema { + return &types.APISchema{ + Schema: &schemas.Schema{ + ID: resourceType, + CollectionMethods: []string{}, + ResourceMethods: []string{}, + ResourceFields: map[string]schemas.Field{ + "name": {Type: "string"}, + "value": {Type: "string"}, + }, + Attributes: map[string]interface{}{ + "group": testGroup, + "version": testVersion, + "resource": resourceType, + "verbs": []string{"get", "list", "watch", "delete", "update", "create"}, + }, + }, + } +} diff --git a/pkg/schema/fake/factory.go b/pkg/schema/fake/factory.go new file mode 100644 index 00000000..9f7d7e72 --- /dev/null +++ b/pkg/schema/fake/factory.go @@ -0,0 +1,110 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/rancher/steve/pkg/schema (interfaces: Factory) + +// Package fake is a generated GoMock package. +package fake + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + types "github.com/rancher/apiserver/pkg/types" + schema "github.com/rancher/steve/pkg/schema" + schema0 "k8s.io/apimachinery/pkg/runtime/schema" + user "k8s.io/apiserver/pkg/authentication/user" +) + +// MockFactory is a mock of Factory interface. +type MockFactory struct { + ctrl *gomock.Controller + recorder *MockFactoryMockRecorder +} + +// MockFactoryMockRecorder is the mock recorder for MockFactory. +type MockFactoryMockRecorder struct { + mock *MockFactory +} + +// NewMockFactory creates a new mock instance. +func NewMockFactory(ctrl *gomock.Controller) *MockFactory { + mock := &MockFactory{ctrl: ctrl} + mock.recorder = &MockFactoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFactory) EXPECT() *MockFactoryMockRecorder { + return m.recorder +} + +// AddTemplate mocks base method. +func (m *MockFactory) AddTemplate(arg0 ...schema.Template) { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "AddTemplate", varargs...) +} + +// AddTemplate indicates an expected call of AddTemplate. +func (mr *MockFactoryMockRecorder) AddTemplate(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddTemplate", reflect.TypeOf((*MockFactory)(nil).AddTemplate), arg0...) +} + +// ByGVK mocks base method. +func (m *MockFactory) ByGVK(arg0 schema0.GroupVersionKind) string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ByGVK", arg0) + ret0, _ := ret[0].(string) + return ret0 +} + +// ByGVK indicates an expected call of ByGVK. +func (mr *MockFactoryMockRecorder) ByGVK(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ByGVK", reflect.TypeOf((*MockFactory)(nil).ByGVK), arg0) +} + +// ByGVR mocks base method. +func (m *MockFactory) ByGVR(arg0 schema0.GroupVersionResource) string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ByGVR", arg0) + ret0, _ := ret[0].(string) + return ret0 +} + +// ByGVR indicates an expected call of ByGVR. +func (mr *MockFactoryMockRecorder) ByGVR(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ByGVR", reflect.TypeOf((*MockFactory)(nil).ByGVR), arg0) +} + +// OnChange mocks base method. +func (m *MockFactory) OnChange(arg0 context.Context, arg1 func()) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "OnChange", arg0, arg1) +} + +// OnChange indicates an expected call of OnChange. +func (mr *MockFactoryMockRecorder) OnChange(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnChange", reflect.TypeOf((*MockFactory)(nil).OnChange), arg0, arg1) +} + +// Schemas mocks base method. +func (m *MockFactory) Schemas(arg0 user.Info) (*types.APISchemas, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Schemas", arg0) + ret0, _ := ret[0].(*types.APISchemas) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Schemas indicates an expected call of Schemas. +func (mr *MockFactoryMockRecorder) Schemas(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Schemas", reflect.TypeOf((*MockFactory)(nil).Schemas), arg0) +} diff --git a/pkg/schema/mock_test.go b/pkg/schema/mock_test.go new file mode 100644 index 00000000..c0b0b284 --- /dev/null +++ b/pkg/schema/mock_test.go @@ -0,0 +1,75 @@ +package schema + +import ( + "crypto/sha256" + "encoding/hex" + "hash" + + "github.com/rancher/steve/pkg/accesscontrol" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/authentication/user" +) + +const ( + insideSeparator = "&" + outsideSeparator = "%" +) + +type mockAccessSetLookup struct { + accessSets map[string]*accesscontrol.AccessSet + currentHash map[string]hash.Hash +} + +func newMockAccessSetLookup() *mockAccessSetLookup { + return &mockAccessSetLookup{ + accessSets: map[string]*accesscontrol.AccessSet{}, + currentHash: map[string]hash.Hash{}, + } +} + +func (m *mockAccessSetLookup) AccessFor(user user.Info) *accesscontrol.AccessSet { + if set, ok := m.accessSets[user.GetName()]; ok { + return set + } + return nil +} + +func (m *mockAccessSetLookup) PurgeUserData(id string) { + var foundKey string + for key, value := range m.accessSets { + if value.ID == id { + foundKey = key + } + } + if foundKey != "" { + delete(m.accessSets, foundKey) + } +} + +func (m *mockAccessSetLookup) AddAccessForUser(user user.Info, verb string, gr schema.GroupResource, namespace string, name string) { + currentAccessSet, ok := m.accessSets[user.GetName()] + var currentHash hash.Hash + if !ok { + currentAccessSet = &accesscontrol.AccessSet{} + currentHash = sha256.New() + } else { + currentHash = m.currentHash[currentAccessSet.ID] + } + currentAccessSet.Add(verb, gr, accesscontrol.Access{Namespace: namespace, ResourceName: name}) + calculateAccessSetID(currentHash, verb, gr, namespace, name) + currentAccessSet.ID = hex.EncodeToString(currentHash.Sum(nil)) + m.accessSets[user.GetName()] = currentAccessSet + m.currentHash[currentAccessSet.ID] = currentHash +} + +func (m *mockAccessSetLookup) Clear() { + m.accessSets = map[string]*accesscontrol.AccessSet{} + m.currentHash = map[string]hash.Hash{} +} + +func calculateAccessSetID(digest hash.Hash, verb string, gr schema.GroupResource, namespace string, name string) { + digest.Write([]byte(verb + insideSeparator)) + digest.Write([]byte(gr.String() + insideSeparator)) + digest.Write([]byte(namespace + insideSeparator)) + digest.Write([]byte(name + outsideSeparator)) +} diff --git a/pkg/schema/table/mapper.go b/pkg/schema/table/mapper.go index 64d4da36..77f44b90 100644 --- a/pkg/schema/table/mapper.go +++ b/pkg/schema/table/mapper.go @@ -3,9 +3,9 @@ package table import ( types2 "github.com/rancher/apiserver/pkg/types" "github.com/rancher/steve/pkg/attributes" - "github.com/rancher/wrangler/pkg/data" - types "github.com/rancher/wrangler/pkg/schemas" - "github.com/rancher/wrangler/pkg/schemas/mappers" + "github.com/rancher/wrangler/v3/pkg/data" + types "github.com/rancher/wrangler/v3/pkg/schemas" + "github.com/rancher/wrangler/v3/pkg/schemas/mappers" ) type Column struct { diff --git a/pkg/server/cli/clicontext.go b/pkg/server/cli/clicontext.go index fcdb416e..f347b924 100644 --- a/pkg/server/cli/clicontext.go +++ b/pkg/server/cli/clicontext.go @@ -7,8 +7,8 @@ import ( authcli "github.com/rancher/steve/pkg/auth/cli" "github.com/rancher/steve/pkg/server" "github.com/rancher/steve/pkg/ui" - "github.com/rancher/wrangler/pkg/kubeconfig" - "github.com/rancher/wrangler/pkg/ratelimit" + "github.com/rancher/wrangler/v3/pkg/kubeconfig" + "github.com/rancher/wrangler/v3/pkg/ratelimit" "github.com/urfave/cli" ) @@ -24,14 +24,14 @@ type Config struct { } func (c *Config) MustServer(ctx context.Context) *server.Server { - cc, err := c.ToServer(ctx) + cc, err := c.ToServer(ctx, false) if err != nil { panic(err) } return cc } -func (c *Config) ToServer(ctx context.Context) (*server.Server, error) { +func (c *Config) ToServer(ctx context.Context, sqlCache bool) (*server.Server, error) { var ( auth steveauth.Middleware ) @@ -52,6 +52,7 @@ func (c *Config) ToServer(ctx context.Context) (*server.Server, error) { return server.New(ctx, restConfig, &server.Options{ AuthMiddleware: auth, Next: ui.New(c.UIPath, c.Offline), + SQLCache: sqlCache, }) } diff --git a/pkg/server/config.go b/pkg/server/config.go index af742649..4d6a36a2 100644 --- a/pkg/server/config.go +++ b/pkg/server/config.go @@ -4,17 +4,17 @@ import ( "context" "time" - "github.com/rancher/wrangler/pkg/generated/controllers/apiextensions.k8s.io" - apiextensionsv1 "github.com/rancher/wrangler/pkg/generated/controllers/apiextensions.k8s.io/v1" - "github.com/rancher/wrangler/pkg/generated/controllers/apiregistration.k8s.io" - apiregistrationv1 "github.com/rancher/wrangler/pkg/generated/controllers/apiregistration.k8s.io/v1" - "github.com/rancher/wrangler/pkg/generated/controllers/core" - corev1 "github.com/rancher/wrangler/pkg/generated/controllers/core/v1" - "github.com/rancher/wrangler/pkg/generated/controllers/rbac" - rbacv1 "github.com/rancher/wrangler/pkg/generated/controllers/rbac/v1" - "github.com/rancher/wrangler/pkg/generic" - "github.com/rancher/wrangler/pkg/ratelimit" - "github.com/rancher/wrangler/pkg/start" + "github.com/rancher/wrangler/v3/pkg/generated/controllers/apiextensions.k8s.io" + apiextensionsv1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/apiextensions.k8s.io/v1" + "github.com/rancher/wrangler/v3/pkg/generated/controllers/apiregistration.k8s.io" + apiregistrationv1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/apiregistration.k8s.io/v1" + "github.com/rancher/wrangler/v3/pkg/generated/controllers/core" + corev1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/core/v1" + "github.com/rancher/wrangler/v3/pkg/generated/controllers/rbac" + rbacv1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/rbac/v1" + "github.com/rancher/wrangler/v3/pkg/generic" + "github.com/rancher/wrangler/v3/pkg/ratelimit" + "github.com/rancher/wrangler/v3/pkg/start" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" ) diff --git a/pkg/server/handler/apiserver.go b/pkg/server/handler/apiserver.go index aaf1a655..e0333acc 100644 --- a/pkg/server/handler/apiserver.go +++ b/pkg/server/handler/apiserver.go @@ -3,7 +3,6 @@ package handler import ( "net/http" - "github.com/rancher/apiserver/pkg/server" apiserver "github.com/rancher/apiserver/pkg/server" "github.com/rancher/apiserver/pkg/types" "github.com/rancher/apiserver/pkg/urlbuilder" @@ -26,7 +25,7 @@ func New(cfg *rest.Config, sf schema.Factory, authMiddleware auth.Middleware, ne a := &apiServer{ sf: sf, - server: server.DefaultAPIServer(), + server: apiserver.DefaultAPIServer(), } a.server.AccessControl = accesscontrol.NewAccessControl() @@ -55,7 +54,7 @@ func New(cfg *rest.Config, sf schema.Factory, authMiddleware auth.Middleware, ne type apiServer struct { sf schema.Factory - server *server.Server + server *apiserver.Server } func (a *apiServer) common(rw http.ResponseWriter, req *http.Request) (*types.APIRequest, bool) { diff --git a/pkg/server/handler/handlers.go b/pkg/server/handler/handlers.go index 0b4fa3e7..79cfe5ba 100644 --- a/pkg/server/handler/handlers.go +++ b/pkg/server/handler/handlers.go @@ -9,11 +9,6 @@ import ( func k8sAPI(sf schema.Factory, apiOp *types.APIRequest) { vars := mux.Vars(apiOp.Request) - group := vars["group"] - if group == "core" { - group = "" - } - apiOp.Name = vars["name"] apiOp.Type = vars["type"] diff --git a/pkg/server/router/router.go b/pkg/server/router/router.go index 1003a4f6..ac6803fd 100644 --- a/pkg/server/router/router.go +++ b/pkg/server/router/router.go @@ -22,7 +22,7 @@ func Routes(h Handlers) http.Handler { m.StrictSlash(true) m.Use(urlbuilder.RedirectRewrite) - m.Path("/").Handler(h.APIRoot).HeadersRegexp("Accepts", ".*json.*") + m.Path("/").Handler(h.APIRoot).HeadersRegexp("Accept", ".*json.*") m.Path("/{name:v1}").Handler(h.APIRoot) m.Path("/v1/{type}").Handler(h.K8sResource) diff --git a/pkg/server/server.go b/pkg/server/server.go index ba379c56..5d5fb6a2 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -18,8 +18,13 @@ import ( "github.com/rancher/steve/pkg/resources/common" "github.com/rancher/steve/pkg/resources/schemas" "github.com/rancher/steve/pkg/schema" + "github.com/rancher/steve/pkg/schema/definitions" "github.com/rancher/steve/pkg/server/handler" "github.com/rancher/steve/pkg/server/router" + metricsStore "github.com/rancher/steve/pkg/stores/metrics" + "github.com/rancher/steve/pkg/stores/proxy" + "github.com/rancher/steve/pkg/stores/sqlpartition" + "github.com/rancher/steve/pkg/stores/sqlproxy" "github.com/rancher/steve/pkg/summarycache" "k8s.io/client-go/rest" ) @@ -47,6 +52,7 @@ type Server struct { aggregationSecretNamespace string aggregationSecretName string + SQLCache bool } type Options struct { @@ -61,6 +67,8 @@ type Options struct { AggregationSecretName string ClusterRegistry string ServerVersion string + // SQLCache enables the SQLite-based lasso caching mechanism + SQLCache bool } func New(ctx context.Context, restConfig *rest.Config, opts *Options) (*Server, error) { @@ -80,6 +88,8 @@ func New(ctx context.Context, restConfig *rest.Config, opts *Options) (*Server, aggregationSecretName: opts.AggregationSecretName, ClusterRegistry: opts.ClusterRegistry, Version: opts.ServerVersion, + // SQLCache enables the SQLite-based lasso caching mechanism + SQLCache: opts.SQLCache, } if err := setup(ctx, server); err != nil { @@ -141,19 +151,57 @@ func setup(ctx context.Context, server *Server) error { if err = resources.DefaultSchemas(ctx, server.BaseSchemas, ccache, server.ClientFactory, sf, server.Version); err != nil { return err } + definitions.Register(ctx, server.BaseSchemas, server.controllers.K8s.Discovery(), + server.controllers.CRD.CustomResourceDefinition(), server.controllers.API.APIService()) summaryCache := summarycache.New(sf, ccache) summaryCache.Start(ctx) - - for _, template := range resources.DefaultSchemaTemplates(cf, server.BaseSchemas, summaryCache, asl, server.controllers.K8s.Discovery()) { - sf.AddTemplate(template) - } - cols, err := common.NewDynamicColumns(server.RESTConfig) if err != nil { return err } + var onSchemasHandler schemacontroller.SchemasHandlerFunc + if server.SQLCache { + s, err := sqlproxy.NewProxyStore(cols, cf, summaryCache, nil) + if err != nil { + panic(err) + } + + errStore := proxy.NewErrorStore( + proxy.NewUnformatterStore( + proxy.NewWatchRefresh( + sqlpartition.NewStore( + s, + asl, + ), + asl, + ), + ), + ) + store := metricsStore.NewMetricsStore(errStore) + // end store setup code + + for _, template := range resources.DefaultSchemaTemplatesForStore(store, server.BaseSchemas, summaryCache, server.controllers.K8s.Discovery()) { + sf.AddTemplate(template) + } + + onSchemasHandler = func(schemas *schema.Collection) error { + if err := ccache.OnSchemas(schemas); err != nil { + return err + } + if err := s.Reset(); err != nil { + return err + } + return nil + } + } else { + for _, template := range resources.DefaultSchemaTemplates(cf, server.BaseSchemas, summaryCache, asl, server.controllers.K8s.Discovery(), server.controllers.Core.Namespace().Cache()) { + sf.AddTemplate(template) + } + onSchemasHandler = ccache.OnSchemas + } + schemas.SetupWatcher(ctx, server.BaseSchemas, asl, sf) schemacontroller.Register(ctx, @@ -162,7 +210,7 @@ func setup(ctx context.Context, server *Server) error { server.controllers.CRD.CustomResourceDefinition(), server.controllers.API.APIService(), server.controllers.K8s.AuthorizationV1().SelfSubjectAccessReviews(), - ccache, + onSchemasHandler, sf) apiServer, handler, err := handler.New(server.RESTConfig, sf, server.authMiddleware, server.next, server.router) @@ -173,6 +221,7 @@ func setup(ctx context.Context, server *Server) error { server.APIServer = apiServer server.Handler = handler server.SchemaFactory = sf + return nil } @@ -202,6 +251,9 @@ func (c *Server) ListenAndServe(ctx context.Context, httpsPort, httpPort int, op c.StartAggregation(ctx) + if len(opts.TLSListenerConfig.SANs) == 0 { + opts.TLSListenerConfig.SANs = []string{"127.0.0.1"} + } if err := server.ListenAndServe(ctx, httpsPort, httpPort, c, opts); err != nil { return err } diff --git a/pkg/stores/metrics/metrics_store.go b/pkg/stores/metrics/metrics_store.go index 1e316a1d..6a434c4b 100644 --- a/pkg/stores/metrics/metrics_store.go +++ b/pkg/stores/metrics/metrics_store.go @@ -63,4 +63,4 @@ func (s *Store) Watch(apiOp *types.APIRequest, schema *types.APISchema, w types. apiEvent, err := s.Store.Watch(apiOp, schema, w) m.RecordProxyStoreResponseTime(err, float64(time.Since(storeStart).Milliseconds())) return apiEvent, err -} \ No newline at end of file +} diff --git a/pkg/stores/partition/listprocessor/processor.go b/pkg/stores/partition/listprocessor/processor.go new file mode 100644 index 00000000..5a80ebc4 --- /dev/null +++ b/pkg/stores/partition/listprocessor/processor.go @@ -0,0 +1,424 @@ +// Package listprocessor contains methods for filtering, sorting, and paginating lists of objects. +package listprocessor + +import ( + "regexp" + "sort" + "strconv" + "strings" + + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/wrangler/v3/pkg/data" + "github.com/rancher/wrangler/v3/pkg/data/convert" + corecontrollers "github.com/rancher/wrangler/v3/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" + projectsOrNamespacesVar = "projectsornamespaces" + projectIDFieldLabel = "field.cattle.io/projectId" + + orOp = "," + notOp = "!" +) + +var opReg = regexp.MustCompile(`[!]?=`) + +type op string + +const ( + eq op = "" + notEq op = "!=" +) + +// 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 + ProjectsOrNamespaces ProjectsOrNamespacesFilter +} + +// Filter represents a field to filter by. +// A subfield in an object is represented in a request query using . notation, e.g. 'metadata.name'. +// The subfield is internally represented as a slice, e.g. [metadata, name]. +type Filter struct { + field []string + match string + op op +} + +// String returns the filter as a query string. +func (f Filter) String() string { + field := strings.Join(f.field, ".") + return field + "=" + f.match +} + +// OrFilter represents a set of possible fields to filter by, where an item may match any filter in the set to be included in the result. +type OrFilter struct { + filters []Filter +} + +// String returns the filter as a query string. +func (f OrFilter) String() string { + var fields strings.Builder + for i, field := range f.filters { + fields.WriteString(strings.Join(field.field, ".")) + fields.WriteByte('=') + fields.WriteString(field.match) + if i < len(f.filters)-1 { + fields.WriteByte(',') + } + } + return fields.String() +} + +// SortOrder represents whether the list should be ascending or descending. +type SortOrder int + +const ( + // ASC stands for ascending order. + ASC SortOrder = iota + // DESC stands for descending (reverse) order. + DESC +) + +// Sort represents the criteria to sort on. +// The subfield to sort by is represented in a request query using . notation, e.g. 'metadata.name'. +// The subfield is internally represented as a slice, e.g. [metadata, name]. +// The order is represented by prefixing the sort key by '-', e.g. sort=-metadata.name. +type Sort struct { + primaryField []string + secondaryField []string + primaryOrder SortOrder + secondaryOrder SortOrder +} + +// String returns the sort parameters as a query string. +func (s Sort) String() string { + field := "" + if s.primaryOrder == DESC { + field = "-" + field + } + field += strings.Join(s.primaryField, ".") + if len(s.secondaryField) > 0 { + field += "," + if s.secondaryOrder == DESC { + field += "-" + } + field += strings.Join(s.secondaryField, ".") + } + return field +} + +// Pagination represents how to return paginated results. +type Pagination struct { + pageSize int + page int +} + +// PageSize returns the integer page size. +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 { + 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 { + orFilters := strings.Split(filters, orOp) + orFilter := OrFilter{} + for _, filter := range orFilters { + var op op + if strings.Contains(filter, "!=") { + op = "!=" + } + filter := opReg.Split(filter, -1) + if len(filter) != 2 { + continue + } + orFilter.filters = append(orFilter.filters, Filter{field: strings.Split(filter[0], "."), match: filter[1], op: op}) + } + filterOpts = append(filterOpts, orFilter) + } + // sort the filter fields so they can be used as a cache key in the store + for _, orFilter := range filterOpts { + sort.Slice(orFilter.filters, func(i, j int) bool { + fieldI := strings.Join(orFilter.filters[i].field, ".") + fieldJ := strings.Join(orFilter.filters[j].field, ".") + return fieldI < fieldJ + }) + } + sort.Slice(filterOpts, func(i, j int) bool { + var fieldI, fieldJ strings.Builder + for _, f := range filterOpts[i].filters { + fieldI.WriteString(strings.Join(f.field, ".")) + } + for _, f := range filterOpts[j].filters { + fieldJ.WriteString(strings.Join(f.field, ".")) + } + return fieldI.String() < fieldJ.String() + }) + opts.Filters = filterOpts + + sortOpts := Sort{} + sortKeys := q.Get(sortParam) + if sortKeys != "" { + sortParts := strings.SplitN(sortKeys, ",", 2) + primaryField := sortParts[0] + if primaryField != "" && primaryField[0] == '-' { + sortOpts.primaryOrder = DESC + primaryField = primaryField[1:] + } + if primaryField != "" { + sortOpts.primaryField = strings.Split(primaryField, ".") + } + if len(sortParts) > 1 { + secondaryField := sortParts[1] + if secondaryField != "" && secondaryField[0] == '-' { + sortOpts.secondaryOrder = DESC + secondaryField = secondaryField[1:] + } + if secondaryField != "" { + sortOpts.secondaryField = strings.Split(secondaryField, ".") + } + } + } + opts.Sort = sortOpts + + var err error + pagination := Pagination{} + pagination.pageSize, err = strconv.Atoi(q.Get(pageSizeParam)) + if err != nil { + pagination.pageSize = 0 + } + pagination.page, err = strconv.Atoi(q.Get(pageParam)) + if err != nil { + pagination.page = 1 + } + opts.Pagination = pagination + + revision := q.Get(revisionParam) + 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. +// The default limit can be explicitly disabled by setting it to zero or negative. +// If the default is accepted, clients must be aware that the list may be incomplete, and use the "continue" token to get the next chunk of results. +func getLimit(apiOp *types.APIRequest) int { + limitString := apiOp.Request.URL.Query().Get(limitParam) + limit, err := strconv.Atoi(limitString) + if err != nil { + limit = defaultLimit + } + return limit +} + +// FilterList accepts a channel of unstructured objects and a slice of filters and returns the filtered list. +// Filters are ANDed together. +func FilterList(list <-chan []unstructured.Unstructured, filters []OrFilter) []unstructured.Unstructured { + result := []unstructured.Unstructured{} + for items := range list { + for _, item := range items { + if len(filters) == 0 { + result = append(result, item) + continue + } + if matchesAll(item.Object, filters) { + result = append(result, item) + } + } + } + return result +} + +func matchesOne(obj map[string]interface{}, filter Filter) bool { + var objValue interface{} + var ok bool + subField := []string{} + for !ok && len(filter.field) > 0 { + objValue, ok = data.GetValue(obj, filter.field...) + if !ok { + subField = append(subField, filter.field[len(filter.field)-1]) + filter.field = filter.field[:len(filter.field)-1] + } + } + if !ok { + return false + } + switch typedVal := objValue.(type) { + case string, int, bool: + if len(subField) > 0 { + return false + } + stringVal := convert.ToString(typedVal) + if strings.Contains(stringVal, filter.match) { + return true + } + case []interface{}: + filter = Filter{field: subField, match: filter.match, op: filter.op} + if matchesOneInList(typedVal, filter) { + return true + } + } + return false +} + +func matchesOneInList(obj []interface{}, filter Filter) bool { + for _, v := range obj { + switch typedItem := v.(type) { + case string, int, bool: + stringVal := convert.ToString(typedItem) + if strings.Contains(stringVal, filter.match) { + return true + } + case map[string]interface{}: + if matchesOne(typedItem, filter) { + return true + } + case []interface{}: + if matchesOneInList(typedItem, filter) { + return true + } + } + } + return false +} + +func matchesAny(obj map[string]interface{}, filter OrFilter) bool { + for _, f := range filter.filters { + matches := matchesOne(obj, f) + if (matches && f.op == eq) || (!matches && f.op == notEq) { + return true + } + } + return false +} + +func matchesAll(obj map[string]interface{}, filters []OrFilter) bool { + for _, f := range filters { + if !matchesAny(obj, f) { + return false + } + } + return true +} + +// SortList sorts the slice by the provided sort criteria. +func SortList(list []unstructured.Unstructured, s Sort) []unstructured.Unstructured { + if len(s.primaryField) == 0 { + return list + } + sort.Slice(list, func(i, j int) bool { + leftPrime := convert.ToString(data.GetValueN(list[i].Object, s.primaryField...)) + rightPrime := convert.ToString(data.GetValueN(list[j].Object, s.primaryField...)) + if leftPrime == rightPrime && len(s.secondaryField) > 0 { + leftSecond := convert.ToString(data.GetValueN(list[i].Object, s.secondaryField...)) + rightSecond := convert.ToString(data.GetValueN(list[j].Object, s.secondaryField...)) + if s.secondaryOrder == ASC { + return leftSecond < rightSecond + } + return rightSecond < leftSecond + } + if s.primaryOrder == ASC { + return leftPrime < rightPrime + } + return rightPrime < leftPrime + }) + return list +} + +// PaginateList returns a subset of the result based on the pagination criteria as well as the total number of pages the caller can expect. +func PaginateList(list []unstructured.Unstructured, p Pagination) ([]unstructured.Unstructured, int) { + if p.pageSize <= 0 { + return list, 0 + } + page := p.page - 1 + if p.page < 1 { + page = 0 + } + pages := len(list) / p.pageSize + if len(list)%p.pageSize != 0 { + pages++ + } + offset := p.pageSize * page + if offset > len(list) { + return []unstructured.Unstructured{}, pages + } + if offset+p.pageSize > len(list) { + return list[offset:], pages + } + 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 new file mode 100644 index 00000000..d5e4fe5a --- /dev/null +++ b/pkg/stores/partition/listprocessor/processor_test.go @@ -0,0 +1,3239 @@ +package listprocessor + +import ( + "testing" + + "github.com/rancher/wrangler/v3/pkg/generic" + "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) { + tests := []struct { + name string + objects [][]unstructured.Unstructured + filters []OrFilter + want []unstructured.Unstructured + }{ + { + name: "single filter", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"data", "color"}, + match: "pink", + }, + }, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + }, + { + name: "multi filter", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"data", "color"}, + match: "pink", + }, + }, + }, + { + filters: []Filter{ + { + field: []string{"metadata", "name"}, + match: "honey", + }, + }, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + }, + { + name: "no matches", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"data", "color"}, + match: "purple", + }, + }, + }, + }, + want: []unstructured.Unstructured{}, + }, + { + name: "no filters", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + }, + filters: []OrFilter{}, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + }, + { + name: "filter field does not match", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"spec", "volumes"}, + match: "hostPath", + }, + }, + }, + }, + want: []unstructured.Unstructured{}, + }, + { + name: "filter subfield does not match", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"data", "productType"}, + match: "tablet", + }, + }, + }, + }, + want: []unstructured.Unstructured{}, + }, + { + name: "almost valid filter key", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"data", "color", "shade"}, + match: "green", + }, + }, + }, + }, + want: []unstructured.Unstructured{}, + }, + { + name: "match string array", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "apple", + }, + "data": map[string]interface{}{ + "colors": []interface{}{ + "pink", + "red", + "green", + "yellow", + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "berry", + }, + "data": map[string]interface{}{ + "colors": []interface{}{ + "blue", + "red", + "black", + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "banana", + }, + "data": map[string]interface{}{ + "colors": []interface{}{ + "yellow", + }, + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"data", "colors"}, + match: "yellow", + }, + }, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "apple", + }, + "data": map[string]interface{}{ + "colors": []interface{}{ + "pink", + "red", + "green", + "yellow", + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "banana", + }, + "data": map[string]interface{}{ + "colors": []interface{}{ + "yellow", + }, + }, + }, + }, + }, + }, + { + name: "match object array", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "apple", + }, + "data": map[string]interface{}{ + "varieties": []interface{}{ + map[string]interface{}{ + "name": "fuji", + "color": "pink", + }, + map[string]interface{}{ + "name": "granny-smith", + "color": "green", + }, + map[string]interface{}{ + "name": "red-delicious", + "color": "red", + }, + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "berry", + }, + "data": map[string]interface{}{ + "varieties": []interface{}{ + map[string]interface{}{ + "name": "blueberry", + "color": "blue", + }, + map[string]interface{}{ + "name": "raspberry", + "color": "red", + }, + map[string]interface{}{ + "name": "blackberry", + "color": "black", + }, + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "banana", + }, + "data": map[string]interface{}{ + "varieties": []interface{}{ + map[string]interface{}{ + "name": "cavendish", + "color": "yellow", + }, + map[string]interface{}{ + "name": "plantain", + "color": "green", + }, + }, + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"data", "varieties", "color"}, + match: "red", + }, + }, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "apple", + }, + "data": map[string]interface{}{ + "varieties": []interface{}{ + map[string]interface{}{ + "name": "fuji", + "color": "pink", + }, + map[string]interface{}{ + "name": "granny-smith", + "color": "green", + }, + map[string]interface{}{ + "name": "red-delicious", + "color": "red", + }, + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "berry", + }, + "data": map[string]interface{}{ + "varieties": []interface{}{ + map[string]interface{}{ + "name": "blueberry", + "color": "blue", + }, + map[string]interface{}{ + "name": "raspberry", + "color": "red", + }, + map[string]interface{}{ + "name": "blackberry", + "color": "black", + }, + }, + }, + }, + }, + }, + }, + { + name: "match nested array", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "apple", + }, + "data": map[string]interface{}{ + "attributes": []interface{}{ + []interface{}{ + "pink", + "green", + "red", + "purple", + }, + []interface{}{ + "fuji", + "granny-smith", + "red-delicious", + "black-diamond", + }, + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "berry", + }, + "data": map[string]interface{}{ + "attributes": []interface{}{ + []interface{}{ + "blue", + "red", + "black", + }, + []interface{}{ + "blueberry", + "raspberry", + "blackberry", + }, + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "banana", + }, + "data": map[string]interface{}{ + "attributes": []interface{}{ + []interface{}{ + "yellow", + "green", + }, + []interface{}{ + "cavendish", + "plantain", + }, + }, + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"data", "attributes"}, + match: "black", + }, + }, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "apple", + }, + "data": map[string]interface{}{ + "attributes": []interface{}{ + []interface{}{ + "pink", + "green", + "red", + "purple", + }, + []interface{}{ + "fuji", + "granny-smith", + "red-delicious", + "black-diamond", + }, + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "berry", + }, + "data": map[string]interface{}{ + "attributes": []interface{}{ + []interface{}{ + "blue", + "red", + "black", + }, + []interface{}{ + "blueberry", + "raspberry", + "blackberry", + }, + }, + }, + }, + }, + }, + }, + { + name: "match nested object array", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "apple", + }, + "data": map[string]interface{}{ + "attributes": []interface{}{ + []interface{}{ + map[string]interface{}{ + "pink": "fuji", + }, + map[string]interface{}{ + "green": "granny-smith", + }, + map[string]interface{}{ + "pink": "honeycrisp", + }, + }, + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "berry", + }, + "data": map[string]interface{}{ + "attributes": []interface{}{ + []interface{}{ + map[string]interface{}{ + "blue": "blueberry", + }, + map[string]interface{}{ + "red": "raspberry", + }, + map[string]interface{}{ + "black": "blackberry", + }, + }, + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "banana", + }, + "data": map[string]interface{}{ + "attributes": []interface{}{ + []interface{}{ + map[string]interface{}{ + "yellow": "cavendish", + }, + map[string]interface{}{ + "green": "plantain", + }, + }, + }, + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"data", "attributes", "green"}, + match: "plantain", + }, + }, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "banana", + }, + "data": map[string]interface{}{ + "attributes": []interface{}{ + []interface{}{ + map[string]interface{}{ + "yellow": "cavendish", + }, + map[string]interface{}{ + "green": "plantain", + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "single or filter, filter on one value", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "pink-lady", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "pomegranate", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"metadata", "name"}, + match: "pink", + }, + { + field: []string{"data", "color"}, + match: "pink", + }, + }, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "pink-lady", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "pomegranate", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + }, + { + name: "single or filter, filter on different value", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "pink-lady", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "pomegranate", + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"metadata", "name"}, + match: "pink", + }, + { + field: []string{"metadata", "name"}, + match: "pom", + }, + }, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "pink-lady", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "pomegranate", + }, + }, + }, + }, + }, + { + name: "single or filter, no matches", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "pink-lady", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "pomegranate", + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"metadata", "name"}, + match: "blue", + }, + { + field: []string{"metadata", "name"}, + match: "watermelon", + }, + }, + }, + }, + want: []unstructured.Unstructured{}, + }, + { + name: "and-ed or filters", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "pink-lady", + }, + "data": map[string]interface{}{ + "flavor": "sweet", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "pomegranate", + }, + "data": map[string]interface{}{ + "color": "pink", + "flavor": "sweet", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "grapefruit", + }, + "data": map[string]interface{}{ + "color": "pink", + "data": map[string]interface{}{ + "flavor": "bitter", + }, + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"metadata", "name"}, + match: "pink", + }, + { + field: []string{"data", "color"}, + match: "pink", + }, + }, + }, + { + filters: []Filter{ + { + field: []string{"data", "flavor"}, + match: "sweet", + }, + }, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "pink-lady", + }, + "data": map[string]interface{}{ + "flavor": "sweet", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "pomegranate", + }, + "data": map[string]interface{}{ + "color": "pink", + "flavor": "sweet", + }, + }, + }, + }, + }, + { + name: "not filter", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"data", "color"}, + match: "pink", + op: "!=", + }, + }, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + }, + { + name: "or'ed not filter", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"data", "color"}, + match: "pink", + op: "!=", + }, + { + field: []string{"data", "color"}, + match: "green", + op: "!=", + }, + }, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + }, + { + name: "mixed or'ed filter", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"data", "color"}, + match: "pink", + op: "!=", + }, + { + field: []string{"metadata", "name"}, + match: "fuji", + }, + }, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + }, + { + name: "anded and or'ed mixed equality filter", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"metadata", "name"}, + match: "fuji", + op: "!=", + }, + }, + }, + { + filters: []Filter{ + { + field: []string{"data", "color"}, + match: "pink", + }, + }, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + }, + { + name: "match string array with not", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "apple", + }, + "data": map[string]interface{}{ + "colors": []interface{}{ + "pink", + "red", + "green", + "yellow", + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "berry", + }, + "data": map[string]interface{}{ + "colors": []interface{}{ + "blue", + "red", + "black", + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "banana", + }, + "data": map[string]interface{}{ + "colors": []interface{}{ + "yellow", + }, + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"data", "colors"}, + match: "yellow", + op: "!=", + }, + }, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "berry", + }, + "data": map[string]interface{}{ + "colors": []interface{}{ + "blue", + "red", + "black", + }, + }, + }, + }, + }, + }, + { + name: "match object array with not", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "apple", + }, + "data": map[string]interface{}{ + "varieties": []interface{}{ + map[string]interface{}{ + "name": "fuji", + "color": "pink", + }, + map[string]interface{}{ + "name": "granny-smith", + "color": "green", + }, + map[string]interface{}{ + "name": "red-delicious", + "color": "red", + }, + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "berry", + }, + "data": map[string]interface{}{ + "varieties": []interface{}{ + map[string]interface{}{ + "name": "blueberry", + "color": "blue", + }, + map[string]interface{}{ + "name": "raspberry", + "color": "red", + }, + map[string]interface{}{ + "name": "blackberry", + "color": "black", + }, + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "banana", + }, + "data": map[string]interface{}{ + "varieties": []interface{}{ + map[string]interface{}{ + "name": "cavendish", + "color": "yellow", + }, + map[string]interface{}{ + "name": "plantain", + "color": "green", + }, + }, + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"data", "varieties", "color"}, + match: "red", + op: "!=", + }, + }, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "banana", + }, + "data": map[string]interface{}{ + "varieties": []interface{}{ + map[string]interface{}{ + "name": "cavendish", + "color": "yellow", + }, + map[string]interface{}{ + "name": "plantain", + "color": "green", + }, + }, + }, + }, + }, + }, + }, + { + name: "match nested array with not", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "apple", + }, + "data": map[string]interface{}{ + "attributes": []interface{}{ + []interface{}{ + "pink", + "green", + "red", + "purple", + }, + []interface{}{ + "fuji", + "granny-smith", + "red-delicious", + "black-diamond", + }, + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "berry", + }, + "data": map[string]interface{}{ + "attributes": []interface{}{ + []interface{}{ + "blue", + "red", + "black", + }, + []interface{}{ + "blueberry", + "raspberry", + "blackberry", + }, + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "banana", + }, + "data": map[string]interface{}{ + "attributes": []interface{}{ + []interface{}{ + "yellow", + "green", + }, + []interface{}{ + "cavendish", + "plantain", + }, + }, + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"data", "attributes"}, + match: "black", + op: "!=", + }, + }, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "banana", + }, + "data": map[string]interface{}{ + "attributes": []interface{}{ + []interface{}{ + "yellow", + "green", + }, + []interface{}{ + "cavendish", + "plantain", + }, + }, + }, + }, + }, + }, + }, + { + name: "match nested object array with mixed equality", + objects: [][]unstructured.Unstructured{ + { + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "apple", + }, + "data": map[string]interface{}{ + "attributes": []interface{}{ + []interface{}{ + map[string]interface{}{ + "pink": "fuji", + }, + map[string]interface{}{ + "green": "granny-smith", + }, + map[string]interface{}{ + "pink": "honeycrisp", + }, + }, + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "berry", + }, + "data": map[string]interface{}{ + "attributes": []interface{}{ + []interface{}{ + map[string]interface{}{ + "blue": "blueberry", + }, + map[string]interface{}{ + "red": "raspberry", + }, + map[string]interface{}{ + "black": "blackberry", + }, + }, + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "fruit", + "metadata": map[string]interface{}{ + "name": "banana", + }, + "data": map[string]interface{}{ + "attributes": []interface{}{ + []interface{}{ + map[string]interface{}{ + "yellow": "cavendish", + }, + map[string]interface{}{ + "green": "plantain", + }, + }, + }, + }, + }, + }, + }, + }, + filters: []OrFilter{ + { + filters: []Filter{ + { + field: []string{"data", "attributes", "green"}, + match: "plantain", + op: "!=", + }, + { + field: []string{"data", "attributes", "green"}, + match: "granny-smith", + }, + }, + }, + { + filters: []Filter{ + { + field: []string{"metadata", "name"}, + match: "banana", + }, + }, + }, + }, + want: []unstructured.Unstructured{}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ch := make(chan []unstructured.Unstructured) + go func() { + for _, o := range test.objects { + ch <- o + } + close(ch) + }() + got := FilterList(ch, test.filters) + assert.Equal(t, test.want, got) + }) + } +} + +func TestSortList(t *testing.T) { + tests := []struct { + name string + objects []unstructured.Unstructured + sort Sort + want []unstructured.Unstructured + }{ + { + name: "sort metadata.name", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + sort: Sort{ + primaryField: []string{"metadata", "name"}, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + }, + { + name: "reverse sort metadata.name", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + sort: Sort{ + primaryField: []string{"metadata", "name"}, + primaryOrder: DESC, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + }, + { + name: "invalid field", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + sort: Sort{ + primaryField: []string{"data", "productType"}, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + }, + { + name: "unsorted", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + sort: Sort{}, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + }, + { + name: "primary sort ascending, secondary sort ascending", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + sort: Sort{ + primaryField: []string{"data", "color"}, + secondaryField: []string{"metadata", "name"}, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + }, + { + name: "primary sort ascending, secondary sort descending", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + sort: Sort{ + primaryField: []string{"data", "color"}, + secondaryField: []string{"metadata", "name"}, + secondaryOrder: DESC, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + }, + { + name: "primary sort descending, secondary sort ascending", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + sort: Sort{ + primaryField: []string{"data", "color"}, + primaryOrder: DESC, + secondaryField: []string{"metadata", "name"}, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + }, + { + name: "primary sort descending, secondary sort descending", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + sort: Sort{ + primaryField: []string{"data", "color"}, + primaryOrder: DESC, + secondaryField: []string{"metadata", "name"}, + secondaryOrder: DESC, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := SortList(test.objects, test.sort) + assert.Equal(t, test.want, got) + }) + } +} + +func TestPaginateList(t *testing.T) { + objects := []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "red-delicious", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "crispin", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "bramley", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "golden-delicious", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "macintosh", + }, + }, + }, + } + tests := []struct { + name string + objects []unstructured.Unstructured + pagination Pagination + want []unstructured.Unstructured + wantPages int + }{ + { + name: "pagesize=3, page=unset", + objects: objects, + pagination: Pagination{ + pageSize: 3, + }, + want: objects[:3], + wantPages: 3, + }, + { + name: "pagesize=3, page=1", + objects: objects, + pagination: Pagination{ + pageSize: 3, + page: 1, + }, + want: objects[:3], + wantPages: 3, + }, + { + name: "pagesize=3, page=2", + objects: objects, + pagination: Pagination{ + pageSize: 3, + page: 2, + }, + want: objects[3:6], + wantPages: 3, + }, + { + name: "pagesize=3, page=last", + objects: objects, + pagination: Pagination{ + pageSize: 3, + page: 3, + }, + want: objects[6:], + wantPages: 3, + }, + { + name: "pagesize=3, page>last", + objects: objects, + pagination: Pagination{ + pageSize: 3, + page: 37, + }, + want: []unstructured.Unstructured{}, + wantPages: 3, + }, + { + name: "pagesize=3, page<0", + objects: objects, + pagination: Pagination{ + pageSize: 3, + page: -4, + }, + want: objects[:3], + wantPages: 3, + }, + { + name: "pagesize=0", + objects: objects, + pagination: Pagination{}, + want: objects, + wantPages: 0, + }, + { + name: "pagesize=-1", + objects: objects, + pagination: Pagination{ + pageSize: -7, + }, + want: objects, + wantPages: 0, + }, + { + name: "even page size, even list size", + objects: objects, + pagination: Pagination{ + pageSize: 2, + page: 2, + }, + want: objects[2:4], + wantPages: 4, + }, + { + name: "even page size, odd list size", + objects: objects[1:], + pagination: Pagination{ + pageSize: 2, + page: 2, + }, + want: objects[3:5], + wantPages: 4, + }, + { + name: "odd page size, even list size", + objects: objects, + pagination: Pagination{ + pageSize: 5, + page: 2, + }, + want: objects[5:], + wantPages: 2, + }, + { + name: "odd page size, odd list size", + objects: objects[1:], + pagination: Pagination{ + pageSize: 3, + page: 2, + }, + want: objects[4:7], + wantPages: 3, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, gotPages := PaginateList(test.objects, test.pagination) + assert.Equal(t, test.want, got) + assert.Equal(t, test.wantPages, gotPages) + }) + } +} + +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 generic.Indexer[*corev1.Namespace]) { + panic("not implemented") +} +func (m mockNamespaceCache) GetByIndex(indexName, key string) ([]*corev1.Namespace, error) { + panic("not implemented") +} diff --git a/pkg/stores/partition/parallel.go b/pkg/stores/partition/parallel.go index e1901d31..5cf97b56 100644 --- a/pkg/stores/partition/parallel.go +++ b/pkg/stores/partition/parallel.go @@ -6,33 +6,48 @@ import ( "encoding/json" "github.com/rancher/apiserver/pkg/types" + "golang.org/x/sync/errgroup" "golang.org/x/sync/semaphore" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) +// Partition represents a named grouping of kubernetes resources, +// such as by namespace or a set of names. type Partition interface { Name() string } +// ParallelPartitionLister defines how a set of partitions will be queried. type ParallelPartitionLister struct { - Lister PartitionLister + // Lister is the lister method for a single partition. + Lister PartitionLister + + // Concurrency is the weight of the semaphore. Concurrency int64 - Partitions []Partition - state *listState - revision string - err error + + // Partitions is the set of partitions that will be concurrently queried. + Partitions []Partition + + state *listState + revision string + err error } -type PartitionLister func(ctx context.Context, partition Partition, cont string, revision string, limit int) (types.APIObjectList, error) +// PartitionLister lists objects for one partition. +type PartitionLister func(ctx context.Context, partition Partition, cont string, revision string, limit int) (*unstructured.UnstructuredList, []types.Warning, error) +// Err returns the latest error encountered. func (p *ParallelPartitionLister) Err() error { return p.err } +// Revision returns the revision for the current list state. func (p *ParallelPartitionLister) Revision() string { return p.revision } +// Continue returns the encoded continue token based on the current list state. func (p *ParallelPartitionLister) Continue() string { if p.state == nil { return "" @@ -56,7 +71,10 @@ func indexOrZero(partitions []Partition, name string) int { return 0 } -func (p *ParallelPartitionLister) List(ctx context.Context, limit int, resume string) (<-chan []types.APIObject, error) { +// List returns a stream of objects up to the requested limit. +// If the continue token is not empty, it decodes it and returns the stream +// starting at the indicated marker. +func (p *ParallelPartitionLister) List(ctx context.Context, limit int, resume, revision string) (<-chan []unstructured.Unstructured, error) { var state listState if resume != "" { bytes, err := base64.StdEncoding.DecodeString(resume) @@ -70,22 +88,43 @@ func (p *ParallelPartitionLister) List(ctx context.Context, limit int, resume st if state.Limit > 0 { limit = state.Limit } + } else { + state.Revision = revision } - result := make(chan []types.APIObject) + result := make(chan []unstructured.Unstructured) go p.feeder(ctx, state, limit, result) return result, nil } +// listState is a representation of the continuation point for a partial list. +// It is encoded as the continue token in the returned response. type listState struct { - Revision string `json:"r,omitempty"` + // Revision is the resourceVersion for the List object. + Revision string `json:"r,omitempty"` + + // PartitionName is the name of the partition. PartitionName string `json:"p,omitempty"` - Continue string `json:"c,omitempty"` - Offset int `json:"o,omitempty"` - Limit int `json:"l,omitempty"` + + // Continue is the continue token returned from Kubernetes for a partially filled list request. + // It is a subfield of the continue token returned from steve. + Continue string `json:"c,omitempty"` + + // Offset is the offset from the start of the list within the partition to begin the result list. + Offset int `json:"o,omitempty"` + + // Limit is the maximum number of items from all partitions to return in the result. + Limit int `json:"l,omitempty"` } -func (p *ParallelPartitionLister) feeder(ctx context.Context, state listState, limit int, result chan []types.APIObject) { +// feeder spawns a goroutine to list resources in each partition and feeds the +// results, in order by partition index, into a channel. +// If the sum of the results from all partitions (by namespaces or names) is +// greater than the limit parameter from the user request or the default of +// 100000, the result is truncated and a continue token is generated that +// indicates the partition and offset for the client to start on in the next +// request. +func (p *ParallelPartitionLister) feeder(ctx context.Context, state listState, limit int, result chan []unstructured.Unstructured) { var ( sem = semaphore.NewWeighted(p.Concurrency) capacity = limit @@ -102,7 +141,7 @@ func (p *ParallelPartitionLister) feeder(ctx context.Context, state listState, l }() for i := indexOrZero(p.Partitions, state.PartitionName); i < len(p.Partitions); i++ { - if capacity <= 0 || isDone(ctx) { + if (limit > 0 && capacity <= 0) || isDone(ctx) { break } @@ -116,6 +155,7 @@ func (p *ParallelPartitionLister) feeder(ctx context.Context, state listState, l // setup a linked list of channel to control insertion order last = next + // state.Revision is decoded from the continue token, there won't be a revision on the first request. if state.Revision == "" { // don't have a revision yet so grab all tickets to set a revision tickets = 3 @@ -125,7 +165,7 @@ func (p *ParallelPartitionLister) feeder(ctx context.Context, state listState, l break } - // make state local + // make state local for this partition state := state eg.Go(func() error { defer sem.Release(tickets) @@ -136,7 +176,7 @@ func (p *ParallelPartitionLister) feeder(ctx context.Context, state listState, l if partition.Name() == state.PartitionName { cont = state.Continue } - list, err := p.Lister(ctx, partition, cont, state.Revision, limit) + list, _, err := p.Lister(ctx, partition, cont, state.Revision, limit) if err != nil { return err } @@ -147,22 +187,25 @@ func (p *ParallelPartitionLister) feeder(ctx context.Context, state listState, l } if state.Revision == "" { - state.Revision = list.Revision + state.Revision = list.GetResourceVersion() } if p.revision == "" { - p.revision = list.Revision + p.revision = list.GetResourceVersion() } - if state.PartitionName == partition.Name() && state.Offset > 0 && state.Offset < len(list.Objects) { - list.Objects = list.Objects[state.Offset:] + // We have already seen the first objects in the list, truncate up to the offset. + if state.PartitionName == partition.Name() && state.Offset > 0 && state.Offset < len(list.Items) { + list.Items = list.Items[state.Offset:] } - if len(list.Objects) > capacity { - result <- list.Objects[:capacity] + // Case 1: the capacity has been reached across all goroutines but the list is still only partial, + // so save the state so that the next page can be requested later. + if limit > 0 && len(list.Items) > capacity { + result <- list.Items[:capacity] // save state to redo this list at this offset p.state = &listState{ - Revision: list.Revision, + Revision: list.GetResourceVersion(), PartitionName: partition.Name(), Continue: cont, Offset: capacity, @@ -170,17 +213,19 @@ func (p *ParallelPartitionLister) feeder(ctx context.Context, state listState, l } capacity = 0 return nil - } else { - result <- list.Objects - capacity -= len(list.Objects) - if list.Continue == "" { - return nil - } - // loop again and get more data - state.Continue = list.Continue - state.PartitionName = partition.Name() - state.Offset = 0 } + result <- list.Items + capacity -= len(list.Items) + // Case 2: all objects have been returned, we are done. + if list.GetContinue() == "" { + return nil + } + // Case 3: we started at an offset and truncated the list to skip the objects up to the offset. + // We're not yet up to capacity and have not retrieved every object, + // so loop again and get more data. + state.Continue = list.GetContinue() + state.PartitionName = partition.Name() + state.Offset = 0 } }) } diff --git a/pkg/stores/partition/store.go b/pkg/stores/partition/store.go index 547eec16..85bfe461 100644 --- a/pkg/stores/partition/store.go +++ b/pkg/stores/partition/store.go @@ -1,25 +1,96 @@ +// Package partition implements a store with parallel partitioning of data +// so that segmented data can be concurrently collected and returned as a single data set. package partition import ( "context" - "net/http" + "fmt" + "os" + "reflect" "strconv" + "time" "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/v3/pkg/generated/controllers/core/v1" + "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/cache" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/apiserver/pkg/endpoints/request" ) +const ( + // Number of list request entries to save before cache replacement. + // Not related to the total size in memory of the cache, as any item could take any amount of memory. + cacheSizeEnv = "CATTLE_REQUEST_CACHE_SIZE_INT" + defaultCacheSize = 1000 + // Set to "false" to enable list request caching. + cacheDisableEnv = "CATTLE_REQUEST_CACHE_DISABLED" +) + +// Partitioner is an interface for interacting with partitions. type Partitioner interface { Lookup(apiOp *types.APIRequest, schema *types.APISchema, verb, id string) (Partition, error) All(apiOp *types.APIRequest, schema *types.APISchema, verb, id string) ([]Partition, error) - Store(apiOp *types.APIRequest, partition Partition) (types.Store, error) + Store(apiOp *types.APIRequest, partition Partition) (UnstructuredStore, error) } +// Store implements types.Store for partitions. type Store struct { - Partitioner Partitioner + 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, namespaceCache corecontrollers.NamespaceCache) *Store { + cacheSize := defaultCacheSize + if v := os.Getenv(cacheSizeEnv); v != "" { + sizeInt, err := strconv.Atoi(v) + if err == nil { + cacheSize = sizeInt + } + } + s := &Store{ + Partitioner: partitioner, + asl: asl, + namespaceCache: namespaceCache, + } + if v := os.Getenv(cacheDisableEnv); v == "false" { + s.listCache = cache.NewLRUExpireCache(cacheSize) + } + return s +} + +type cacheKey struct { + chunkSize int + resume string + filters string + sort string + pageSize int + accessID string + resourcePath string + revision string +} + +// UnstructuredStore is like types.Store but deals in k8s unstructured objects instead of apiserver types. +type UnstructuredStore interface { + ByID(apiOp *types.APIRequest, schema *types.APISchema, id string) (*unstructured.Unstructured, []types.Warning, error) + List(apiOp *types.APIRequest, schema *types.APISchema) (*unstructured.UnstructuredList, []types.Warning, error) + Create(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject) (*unstructured.Unstructured, []types.Warning, error) + Update(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject, id string) (*unstructured.Unstructured, []types.Warning, error) + Delete(apiOp *types.APIRequest, schema *types.APISchema, id string) (*unstructured.Unstructured, []types.Warning, error) + Watch(apiOp *types.APIRequest, schema *types.APISchema, w types.WatchRequest) (chan watch.Event, error) } -func (s *Store) getStore(apiOp *types.APIRequest, schema *types.APISchema, verb, id string) (types.Store, error) { +func (s *Store) getStore(apiOp *types.APIRequest, schema *types.APISchema, verb, id string) (UnstructuredStore, error) { p, err := s.Partitioner.Lookup(apiOp, schema, verb, id) if err != nil { return nil, err @@ -28,29 +99,39 @@ func (s *Store) getStore(apiOp *types.APIRequest, schema *types.APISchema, verb, return s.Partitioner.Store(apiOp, p) } +// Delete deletes an object from a store. func (s *Store) Delete(apiOp *types.APIRequest, schema *types.APISchema, id string) (types.APIObject, error) { target, err := s.getStore(apiOp, schema, "delete", id) if err != nil { return types.APIObject{}, err } - return target.Delete(apiOp, schema, id) + obj, warnings, err := target.Delete(apiOp, schema, id) + if err != nil { + return types.APIObject{}, err + } + return ToAPI(schema, obj, warnings), nil } +// ByID looks up a single object by its ID. func (s *Store) ByID(apiOp *types.APIRequest, schema *types.APISchema, id string) (types.APIObject, error) { target, err := s.getStore(apiOp, schema, "get", id) if err != nil { return types.APIObject{}, err } - return target.ByID(apiOp, schema, id) + obj, warnings, err := target.ByID(apiOp, schema, id) + if err != nil { + return types.APIObject{}, err + } + return ToAPI(schema, obj, warnings), nil } func (s *Store) listPartition(ctx context.Context, apiOp *types.APIRequest, schema *types.APISchema, partition Partition, - cont string, revision string, limit int) (types.APIObjectList, error) { + cont string, revision string, limit int) (*unstructured.UnstructuredList, []types.Warning, error) { store, err := s.Partitioner.Store(apiOp, partition) if err != nil { - return types.APIObjectList{}, err + return nil, nil, err } req := apiOp.Clone() @@ -58,7 +139,10 @@ func (s *Store) listPartition(ctx context.Context, apiOp *types.APIRequest, sche values := req.Request.URL.Query() values.Set("continue", cont) - values.Set("revision", revision) + if revision != "" && cont == "" { + values.Set("resourceVersion", revision) + values.Set("resourceVersionMatch", "Exact") // supported since k8s 1.19 + } if limit > 0 { values.Set("limit", strconv.Itoa(limit)) } else { @@ -69,59 +153,137 @@ func (s *Store) listPartition(ctx context.Context, apiOp *types.APIRequest, sche return store.List(req, schema) } +// List returns a list of objects across all applicable partitions. +// If pagination parameters are used, it returns a segment of the list. func (s *Store) List(apiOp *types.APIRequest, schema *types.APISchema) (types.APIObjectList, error) { var ( result types.APIObjectList ) - paritions, err := s.Partitioner.All(apiOp, schema, "list", "") + partitions, err := s.Partitioner.All(apiOp, schema, "list", "") if err != nil { return result, err } lister := ParallelPartitionLister{ - Lister: func(ctx context.Context, partition Partition, cont string, revision string, limit int) (types.APIObjectList, error) { + Lister: func(ctx context.Context, partition Partition, cont string, revision string, limit int) (*unstructured.UnstructuredList, []types.Warning, error) { return s.listPartition(ctx, apiOp, schema, partition, cont, revision, limit) }, Concurrency: 3, - Partitions: paritions, + Partitions: partitions, } - resume := apiOp.Request.URL.Query().Get("continue") - limit := getLimit(apiOp.Request) + opts := listprocessor.ParseQuery(apiOp) - list, err := lister.List(apiOp.Context(), limit, resume) - if err != nil { - return result, err + var list []unstructured.Unstructured + var key cacheKey + if s.listCache != nil { + key, err = s.getCacheKey(apiOp, opts) + if err != nil { + return result, err + } + + if key.revision != "" { + cachedList, ok := s.listCache.Get(key) + if ok { + logrus.Tracef("found cached list for query %s?%s", apiOp.Request.URL.Path, apiOp.Request.URL.RawQuery) + list = cachedList.(*unstructured.UnstructuredList).Items + result.Continue = cachedList.(*unstructured.UnstructuredList).GetContinue() + result.Revision = key.revision + } + } } + if list == nil { // did not look in cache or was not found in cache + stream, err := lister.List(apiOp.Context(), opts.ChunkSize, opts.Resume, opts.Revision) + if err != nil { + return result, err + } + list = listprocessor.FilterList(stream, opts.Filters) + // Check for any errors returned during the parallel listing requests. + // We don't want to cache the list or bother with further processing if the list is empty or corrupt. + // FilterList guarantees that the stream has been consumed and the error is populated if there is any. + if lister.Err() != nil { + return result, lister.Err() + } + list = listprocessor.SortList(list, opts.Sort) + result.Revision = lister.Revision() + listToCache := &unstructured.UnstructuredList{ + Items: list, + } + list = listprocessor.FilterByProjectsAndNamespaces(list, opts.ProjectsOrNamespaces, s.namespaceCache) + c := lister.Continue() + if c != "" { + listToCache.SetContinue(c) + } + if s.listCache != nil { + key.revision = result.Revision + s.listCache.Add(key, listToCache, 30*time.Minute) + } + result.Continue = lister.Continue() + } + result.Count = len(list) + list, pages := listprocessor.PaginateList(list, opts.Pagination) - for items := range list { - result.Objects = append(result.Objects, items...) + for _, item := range list { + item := item.DeepCopy() + result.Objects = append(result.Objects, ToAPI(schema, item, nil)) } - result.Revision = lister.Revision() - result.Continue = lister.Continue() + result.Pages = pages return result, lister.Err() } +// getCacheKey returns a hashable struct identifying a unique user and request. +func (s *Store) getCacheKey(apiOp *types.APIRequest, opts *listprocessor.ListOptions) (cacheKey, error) { + user, ok := request.UserFrom(apiOp.Request.Context()) + if !ok { + return cacheKey{}, fmt.Errorf("could not find user in request") + } + filters := "" + for _, f := range opts.Filters { + filters = filters + f.String() + } + return cacheKey{ + chunkSize: opts.ChunkSize, + resume: opts.Resume, + filters: filters, + sort: opts.Sort.String(), + pageSize: opts.Pagination.PageSize(), + accessID: s.asl.AccessFor(user).ID, + resourcePath: apiOp.Request.URL.Path, + revision: opts.Revision, + }, nil +} + +// Create creates a single object in the store. func (s *Store) Create(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject) (types.APIObject, error) { target, err := s.getStore(apiOp, schema, "create", "") if err != nil { return types.APIObject{}, err } - return target.Create(apiOp, schema, data) + obj, warnings, err := target.Create(apiOp, schema, data) + if err != nil { + return types.APIObject{}, err + } + return ToAPI(schema, obj, warnings), nil } +// Update updates a single object in the store. func (s *Store) Update(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject, id string) (types.APIObject, error) { target, err := s.getStore(apiOp, schema, "update", id) if err != nil { return types.APIObject{}, err } - return target.Update(apiOp, schema, data, id) + obj, warnings, err := target.Update(apiOp, schema, data, id) + if err != nil { + return types.APIObject{}, err + } + return ToAPI(schema, obj, warnings), nil } +// Watch returns a channel of events for a list or resource. func (s *Store) Watch(apiOp *types.APIRequest, schema *types.APISchema, wr types.WatchRequest) (chan types.APIEvent, error) { partitions, err := s.Partitioner.All(apiOp, schema, "watch", wr.ID) if err != nil { @@ -148,7 +310,7 @@ func (s *Store) Watch(apiOp *types.APIRequest, schema *types.APISchema, wr types return err } for i := range c { - response <- i + response <- ToAPIEvent(apiOp, schema, i) } return nil }) @@ -164,14 +326,80 @@ func (s *Store) Watch(apiOp *types.APIRequest, schema *types.APISchema, wr types return response, nil } -func getLimit(req *http.Request) int { - limitString := req.URL.Query().Get("limit") - limit, err := strconv.Atoi(limitString) +func ToAPI(schema *types.APISchema, obj runtime.Object, warnings []types.Warning) types.APIObject { + if obj == nil || reflect.ValueOf(obj).IsNil() { + return types.APIObject{} + } + + if unstr, ok := obj.(*unstructured.Unstructured); ok { + obj = moveToUnderscore(unstr) + } + + apiObject := types.APIObject{ + Type: schema.ID, + Object: obj, + } + + m, err := meta.Accessor(obj) if err != nil { - limit = 0 + return apiObject } - if limit <= 0 { - limit = 100000 + + id := m.GetName() + ns := m.GetNamespace() + if ns != "" { + id = fmt.Sprintf("%s/%s", ns, id) } - return limit + + apiObject.ID = id + apiObject.Warnings = warnings + return apiObject +} + +func moveToUnderscore(obj *unstructured.Unstructured) *unstructured.Unstructured { + if obj == nil { + return nil + } + + for k := range types.ReservedFields { + v, ok := obj.Object[k] + if ok { + delete(obj.Object, k) + obj.Object["_"+k] = v + } + } + + return obj +} + +func ToAPIEvent(apiOp *types.APIRequest, schema *types.APISchema, event watch.Event) types.APIEvent { + name := types.ChangeAPIEvent + switch event.Type { + case watch.Deleted: + name = types.RemoveAPIEvent + case watch.Added: + name = types.CreateAPIEvent + case watch.Error: + name = "resource.error" + } + + apiEvent := types.APIEvent{ + Name: name, + } + + if event.Type == watch.Error { + status, _ := event.Object.(*metav1.Status) + apiEvent.Error = fmt.Errorf(status.Message) + return apiEvent + } + + apiEvent.Object = ToAPI(schema, event.Object, nil) + + m, err := meta.Accessor(event.Object) + if err != nil { + return apiEvent + } + + apiEvent.Revision = m.GetResourceVersion() + return apiEvent } diff --git a/pkg/stores/partition/store_test.go b/pkg/stores/partition/store_test.go new file mode 100644 index 00000000..41b464df --- /dev/null +++ b/pkg/stores/partition/store_test.go @@ -0,0 +1,2421 @@ +package partition + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "fmt" + "net/http" + "net/url" + "strconv" + "testing" + + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/steve/pkg/accesscontrol" + "github.com/rancher/wrangler/v3/pkg/generic" + "github.com/rancher/wrangler/v3/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" +) + +func TestList(t *testing.T) { + tests := []struct { + name string + apiOps []*types.APIRequest + access []map[string]string + partitions map[string][]Partition + objects map[string]*unstructured.UnstructuredList + want []types.APIObjectList + wantCache []mockCache + disableCache bool + wantListCalls []map[string]int + }{ + { + name: "basic", + apiOps: []*types.APIRequest{ + newRequest("", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 1, + Objects: []types.APIObject{ + newApple("fuji").toObj(), + }, + }, + }, + }, + { + name: "limit and continue", + apiOps: []*types.APIRequest{ + newRequest("limit=1", "user1"), + newRequest(fmt.Sprintf("limit=1&continue=%s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"p":"all","c":"%s","l":1}`, base64.StdEncoding.EncodeToString([]byte("granny-smith")))))), "user1"), + newRequest(fmt.Sprintf("limit=1&continue=%s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"p":"all","c":"%s","l":1}`, base64.StdEncoding.EncodeToString([]byte("crispin")))))), "user1"), + newRequest("limit=-1", "user1"), + }, + access: []map[string]string{ + { + "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").Unstructured, + newApple("granny-smith").Unstructured, + newApple("crispin").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 1, + Continue: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"p":"all","c":"%s","l":1}`, base64.StdEncoding.EncodeToString([]byte("granny-smith"))))), + Objects: []types.APIObject{ + newApple("fuji").toObj(), + }, + }, + { + Count: 1, + Continue: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"p":"all","c":"%s","l":1}`, base64.StdEncoding.EncodeToString([]byte("crispin"))))), + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 1, + Objects: []types.APIObject{ + newApple("crispin").toObj(), + }, + }, + { + Count: 3, + Objects: []types.APIObject{ + newApple("fuji").toObj(), + newApple("granny-smith").toObj(), + newApple("crispin").toObj(), + }, + }, + }, + }, + { + name: "multi-partition", + apiOps: []*types.APIRequest{ + newRequest("", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "green", + }, + mockPartition{ + name: "yellow", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "pink": { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + }, + }, + "green": { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + }, + }, + "yellow": { + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + newApple("crispin").toObj(), + }, + }, + }, + }, + { + name: "multi-partition with limit and continue", + apiOps: []*types.APIRequest{ + newRequest("limit=3", "user1"), + newRequest(fmt.Sprintf("limit=3&continue=%s", base64.StdEncoding.EncodeToString([]byte(`{"p":"green","o":1,"l":3}`))), "user1"), + newRequest(fmt.Sprintf("limit=3&continue=%s", base64.StdEncoding.EncodeToString([]byte(`{"p":"red","l":3}`))), "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "pink", + }, + mockPartition{ + name: "green", + }, + mockPartition{ + name: "yellow", + }, + mockPartition{ + name: "red", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "pink": { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("honeycrisp").Unstructured, + }, + }, + "green": { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + "yellow": { + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + newApple("golden-delicious").Unstructured, + }, + }, + "red": { + Items: []unstructured.Unstructured{ + newApple("red-delicious").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 3, + Continue: base64.StdEncoding.EncodeToString([]byte(`{"p":"green","o":1,"l":3}`)), + Objects: []types.APIObject{ + newApple("fuji").toObj(), + newApple("honeycrisp").toObj(), + newApple("granny-smith").toObj(), + }, + }, + { + Count: 3, + Continue: base64.StdEncoding.EncodeToString([]byte(`{"p":"red","l":3}`)), + Objects: []types.APIObject{ + newApple("bramley").toObj(), + newApple("crispin").toObj(), + newApple("golden-delicious").toObj(), + }, + }, + { + Count: 1, + Objects: []types.APIObject{ + newApple("red-delicious").toObj(), + }, + }, + }, + }, + { + name: "with filters", + apiOps: []*types.APIRequest{ + newRequest("filter=data.color=green", "user1"), + newRequest("filter=data.color=green&filter=metadata.name=bramley", "user1"), + newRequest("filter=data.color=green,data.color=pink", "user1"), + newRequest("filter=data.color=green,data.color=pink&filter=metadata.name=fuji", "user1"), + newRequest("filter=data.color=green,data.color=pink&filter=metadata.name=crispin", "user1"), + newRequest("filter=data.color!=green", "user1"), + newRequest("filter=data.color!=green,metadata.name=granny-smith", "user1"), + newRequest("filter=data.color!=green&filter=metadata.name!=crispin", "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").Unstructured, + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + newApple("crispin").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + newApple("bramley").toObj(), + }, + }, + { + Count: 1, + Objects: []types.APIObject{ + newApple("bramley").toObj(), + }, + }, + { + Count: 3, + Objects: []types.APIObject{ + newApple("fuji").toObj(), + newApple("granny-smith").toObj(), + newApple("bramley").toObj(), + }, + }, + { + Count: 1, + Objects: []types.APIObject{ + newApple("fuji").toObj(), + }, + }, + { + Count: 0, + }, + { + Count: 2, + Objects: []types.APIObject{ + newApple("fuji").toObj(), + newApple("crispin").toObj(), + }, + }, + { + Count: 3, + Objects: []types.APIObject{ + newApple("fuji").toObj(), + newApple("granny-smith").toObj(), + newApple("crispin").toObj(), + }, + }, + { + Count: 1, + Objects: []types.APIObject{ + newApple("fuji").toObj(), + }, + }, + }, + }, + { + name: "multi-partition with filters", + apiOps: []*types.APIRequest{ + newRequest("filter=data.category=baking", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "pink", + }, + mockPartition{ + name: "green", + }, + mockPartition{ + name: "yellow", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "pink": { + Items: []unstructured.Unstructured{ + newApple("fuji").with(map[string]string{"category": "eating"}).Unstructured, + newApple("honeycrisp").with(map[string]string{"category": "eating,baking"}).Unstructured, + }, + }, + "green": { + Items: []unstructured.Unstructured{ + newApple("granny-smith").with(map[string]string{"category": "baking"}).Unstructured, + newApple("bramley").with(map[string]string{"category": "eating"}).Unstructured, + }, + }, + "yellow": { + Items: []unstructured.Unstructured{ + newApple("crispin").with(map[string]string{"category": "baking"}).Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 3, + Objects: []types.APIObject{ + newApple("honeycrisp").with(map[string]string{"category": "eating,baking"}).toObj(), + newApple("granny-smith").with(map[string]string{"category": "baking"}).toObj(), + newApple("crispin").with(map[string]string{"category": "baking"}).toObj(), + }, + }, + }, + }, + { + name: "with sorting", + apiOps: []*types.APIRequest{ + newRequest("sort=metadata.name", "user1"), + newRequest("sort=-metadata.name", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + newApple("crispin").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 4, + Objects: []types.APIObject{ + newApple("bramley").toObj(), + newApple("crispin").toObj(), + newApple("fuji").toObj(), + newApple("granny-smith").toObj(), + }, + }, + { + Count: 4, + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + newApple("fuji").toObj(), + newApple("crispin").toObj(), + newApple("bramley").toObj(), + }, + }, + }, + }, + { + name: "sorting with secondary sort", + apiOps: []*types.APIRequest{ + newRequest("sort=data.color,metadata.name,", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("honeycrisp").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 3, + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + newApple("fuji").toObj(), + newApple("honeycrisp").toObj(), + }, + }, + }, + }, + { + name: "sorting with missing primary sort is unsorted", + apiOps: []*types.APIRequest{ + newRequest("sort=,metadata.name", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("honeycrisp").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 3, + Objects: []types.APIObject{ + newApple("fuji").toObj(), + newApple("honeycrisp").toObj(), + newApple("granny-smith").toObj(), + }, + }, + }, + }, + { + name: "sorting with missing secondary sort is single-column sorted", + apiOps: []*types.APIRequest{ + newRequest("sort=metadata.name,", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("honeycrisp").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 3, + Objects: []types.APIObject{ + newApple("fuji").toObj(), + newApple("granny-smith").toObj(), + newApple("honeycrisp").toObj(), + }, + }, + }, + }, + { + name: "multi-partition sort=metadata.name", + apiOps: []*types.APIRequest{ + newRequest("sort=metadata.name", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "green", + }, + mockPartition{ + name: "yellow", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "pink": { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + }, + }, + "green": { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + }, + }, + "yellow": { + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Objects: []types.APIObject{ + newApple("crispin").toObj(), + newApple("granny-smith").toObj(), + }, + }, + }, + }, + { + name: "pagination", + apiOps: []*types.APIRequest{ + newRequest("pagesize=1", "user1"), + newRequest("pagesize=1&page=2&revision=42", "user1"), + newRequest("pagesize=1&page=3&revision=42", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "42", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("fuji").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + }, + }, + wantCache: []mockCache{ + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + }, + wantListCalls: []map[string]int{ + {"all": 1}, + {"all": 1}, + {"all": 1}, + }, + }, + { + name: "access-change pagination", + apiOps: []*types.APIRequest{ + newRequest("pagesize=1", "user1"), + newRequest("pagesize=1&page=2&revision=42", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleB", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "42", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("fuji").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + }, + }, + wantCache: []mockCache{ + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleB"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + }, + wantListCalls: []map[string]int{ + {"all": 1}, + {"all": 2}, + }, + }, + { + name: "pagination with cache disabled", + apiOps: []*types.APIRequest{ + newRequest("pagesize=1", "user1"), + newRequest("pagesize=1&page=2&revision=42", "user1"), + newRequest("pagesize=1&page=3&revision=42", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "42", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("fuji").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + }, + }, + wantCache: []mockCache{}, + disableCache: true, + wantListCalls: []map[string]int{ + {"all": 1}, + {"all": 2}, + {"all": 3}, + }, + }, + { + name: "multi-partition pagesize=1", + apiOps: []*types.APIRequest{ + newRequest("pagesize=1", "user1"), + newRequest("pagesize=1&page=2&revision=102", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "green", + }, + mockPartition{ + name: "yellow", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "pink": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "101", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + }, + }, + "green": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "102", + }, + }, + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + }, + }, + "yellow": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "103", + }, + }, + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Pages: 2, + Revision: "102", + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "102", + Objects: []types.APIObject{ + newApple("crispin").toObj(), + }, + }, + }, + wantCache: []mockCache{ + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("crispin").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("crispin").Unstructured, + }, + }, + }, + }, + }, + wantListCalls: []map[string]int{ + {"green": 1, "yellow": 1}, + {"green": 1, "yellow": 1}, + }, + }, + { + name: "pagesize=1 & limit=2 & continue", + apiOps: []*types.APIRequest{ + newRequest("pagesize=1&limit=2", "user1"), + newRequest("pagesize=1&page=2&limit=2", "user1"), // does not use cache + newRequest("pagesize=1&page=2&revision=42&limit=2", "user1"), // uses cache + newRequest("pagesize=1&page=3&revision=42&limit=2", "user1"), // next page from cache + newRequest(fmt.Sprintf("pagesize=1&revision=42&limit=2&continue=%s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`)))))), "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "42", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + newApple("crispin").Unstructured, + newApple("red-delicious").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Pages: 2, + Revision: "42", + Continue: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + Objects: []types.APIObject{ + newApple("fuji").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Continue: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Continue: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Continue: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("crispin").toObj(), + }, + }, + }, + wantCache: []mockCache{ + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 2, + resume: "", + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "continue": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 2, + resume: "", + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "continue": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 2, + resume: "", + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "continue": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 2, + resume: "", + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "continue": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 2, + resume: "", + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "continue": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + { + chunkSize: 2, + resume: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + newApple("red-delicious").Unstructured, + }, + }, + }, + }, + }, + wantListCalls: []map[string]int{ + {"all": 2}, + {"all": 4}, + {"all": 4}, + {"all": 4}, + {"all": 5}, + }, + }, + { + name: "multi-user pagination", + apiOps: []*types.APIRequest{ + newRequest("pagesize=1", "user1"), + newRequest("pagesize=1", "user2"), + newRequest("pagesize=1&page=2&revision=42", "user1"), + newRequest("pagesize=1&page=2&revision=42", "user2"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user2": "roleB", + }, + { + "user1": "roleA", + }, + { + "user2": "roleB", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + "user2": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "42", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("fuji").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("fuji").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + }, + wantCache: []mockCache{ + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user2", "roleB"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user2", "roleB"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user2", "roleB"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + }, + wantListCalls: []map[string]int{ + {"all": 1}, + {"all": 2}, + {"all": 2}, + {"all": 2}, + }, + }, + { + name: "multi-partition multi-user pagination", + apiOps: []*types.APIRequest{ + newRequest("pagesize=1", "user1"), + newRequest("pagesize=1", "user2"), + newRequest("pagesize=1&page=2&revision=102", "user1"), + newRequest("pagesize=1&page=2&revision=103", "user2"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user2": "roleB", + }, + { + "user1": "roleA", + }, + { + "user2": "roleB", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "green", + }, + }, + "user2": { + mockPartition{ + name: "yellow", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "pink": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "101", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + }, + }, + "green": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "102", + }, + }, + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + "yellow": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "103", + }, + }, + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + newApple("golden-delicious").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Pages: 2, + Revision: "102", + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "103", + Objects: []types.APIObject{ + newApple("crispin").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "102", + Objects: []types.APIObject{ + newApple("bramley").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "103", + Objects: []types.APIObject{ + newApple("golden-delicious").toObj(), + }, + }, + }, + wantCache: []mockCache{ + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + cacheKey{ + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user2", "roleB"), + resourcePath: "/apples", + revision: "103", + }: { + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + newApple("golden-delicious").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user2", "roleB"), + resourcePath: "/apples", + revision: "103", + }: { + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + newApple("golden-delicious").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user2", "roleB"), + resourcePath: "/apples", + revision: "103", + }: { + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + newApple("golden-delicious").Unstructured, + }, + }, + }, + }, + }, + wantListCalls: []map[string]int{ + {"green": 1, "yellow": 0}, + {"green": 1, "yellow": 1}, + {"green": 1, "yellow": 1}, + {"green": 1, "yellow": 1}, + }, + }, + { + name: "multi-partition access-change pagination", + apiOps: []*types.APIRequest{ + newRequest("pagesize=1", "user1"), + newRequest("pagesize=1&page=2&revision=102", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleB", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "green", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "pink": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "101", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + }, + }, + "green": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "102", + }, + }, + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + "yellow": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "103", + }, + }, + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + newApple("golden-delicious").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Pages: 2, + Revision: "102", + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "102", + Objects: []types.APIObject{ + newApple("bramley").toObj(), + }, + }, + }, + wantCache: []mockCache{ + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + cacheKey{ + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleB"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + }, + }, + }, + wantListCalls: []map[string]int{ + {"green": 1}, + {"green": 2}, + }, + }, + { + name: "pagination with or filters", + apiOps: []*types.APIRequest{ + newRequest("filter=metadata.name=el,data.color=el&pagesize=2", "user1"), + newRequest("filter=metadata.name=el,data.color=el&pagesize=2&page=2&revision=42", "user1"), + newRequest("filter=metadata.name=el,data.color=el&pagesize=2&page=3&revision=42", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "42", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + newApple("red-delicious").Unstructured, + newApple("golden-delicious").Unstructured, + newApple("crispin").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 3, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("red-delicious").toObj(), + newApple("golden-delicious").toObj(), + }, + }, + { + Count: 3, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("crispin").toObj(), + }, + }, + { + Count: 3, + Pages: 2, + Revision: "42", + }, + }, + wantCache: []mockCache{ + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + filters: "data.color=el,metadata.name=el", + pageSize: 2, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("red-delicious").Unstructured, + newApple("golden-delicious").Unstructured, + newApple("crispin").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + filters: "data.color=el,metadata.name=el", + pageSize: 2, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("red-delicious").Unstructured, + newApple("golden-delicious").Unstructured, + newApple("crispin").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + filters: "data.color=el,metadata.name=el", + pageSize: 2, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("red-delicious").Unstructured, + newApple("golden-delicious").Unstructured, + newApple("crispin").Unstructured, + }, + }, + }, + }, + }, + wantListCalls: []map[string]int{ + {"all": 1}, + {"all": 1}, + {"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) { + schema := &types.APISchema{Schema: &schemas.Schema{ID: "apple"}} + stores := map[string]UnstructuredStore{} + for _, partitions := range test.partitions { + for _, p := range partitions { + stores[p.Name()] = &mockStore{ + contents: test.objects[p.Name()], + } + } + } + asl := &mockAccessSetLookup{userRoles: test.access} + if !test.disableCache { + t.Setenv("CATTLE_REQUEST_CACHE_DISABLED", "false") + } + store := NewStore(mockPartitioner{ + stores: stores, + partitions: test.partitions, + }, asl, mockNamespaceCache{}) + for i, req := range test.apiOps { + got, gotErr := store.List(req, schema) + assert.Nil(t, gotErr) + assert.Equal(t, test.want[i], got) + if test.disableCache { + assert.Nil(t, store.listCache) + } + if len(test.wantCache) > 0 { + assert.Equal(t, len(test.wantCache[i].contents), len(store.listCache.Keys())) + for k, v := range test.wantCache[i].contents { + cachedVal, _ := store.listCache.Get(k) + assert.Equal(t, v, cachedVal) + } + } + if len(test.wantListCalls) > 0 { + for name, _ := range store.Partitioner.(mockPartitioner).stores { + assert.Equal(t, test.wantListCalls[i][name], store.Partitioner.(mockPartitioner).stores[name].(*mockStore).called) + } + } + } + }) + } +} + +func TestListByRevision(t *testing.T) { + + schema := &types.APISchema{Schema: &schemas.Schema{ID: "apple"}} + asl := &mockAccessSetLookup{userRoles: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + }} + store := NewStore(mockPartitioner{ + stores: map[string]UnstructuredStore{ + "all": &mockVersionedStore{ + versions: []mockStore{ + { + contents: &unstructured.UnstructuredList{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "1", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + }, + }, + }, + { + contents: &unstructured.UnstructuredList{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "2", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + }, asl, mockNamespaceCache{}) + req := newRequest("", "user1") + + got, gotErr := store.List(req, schema) + assert.Nil(t, gotErr) + wantVersion := "2" + assert.Equal(t, wantVersion, got.Revision) + + req = newRequest("revision=1", "user1") + got, gotErr = store.List(req, schema) + assert.Nil(t, gotErr) + wantVersion = "1" + assert.Equal(t, wantVersion, got.Revision) +} + +type mockPartitioner struct { + stores map[string]UnstructuredStore + partitions map[string][]Partition +} + +func (m mockPartitioner) Lookup(apiOp *types.APIRequest, schema *types.APISchema, verb, id string) (Partition, error) { + panic("not implemented") +} + +func (m mockPartitioner) All(apiOp *types.APIRequest, schema *types.APISchema, verb, id string) ([]Partition, error) { + user, _ := request.UserFrom(apiOp.Request.Context()) + return m.partitions[user.GetName()], nil +} + +func (m mockPartitioner) Store(apiOp *types.APIRequest, partition Partition) (UnstructuredStore, error) { + return m.stores[partition.Name()], nil +} + +type mockPartition struct { + name string +} + +func (m mockPartition) Name() string { + return m.name +} + +type mockStore struct { + contents *unstructured.UnstructuredList + partition mockPartition + called int +} + +func (m *mockStore) List(apiOp *types.APIRequest, schema *types.APISchema) (*unstructured.UnstructuredList, []types.Warning, error) { + m.called++ + query, _ := url.ParseQuery(apiOp.Request.URL.RawQuery) + l := query.Get("limit") + if l == "" { + return m.contents, nil, nil + } + i := 0 + if c := query.Get("continue"); c != "" { + start, _ := base64.StdEncoding.DecodeString(c) + for j, obj := range m.contents.Items { + if string(start) == obj.GetName() { + i = j + break + } + } + } + lInt, _ := strconv.Atoi(l) + contents := m.contents.DeepCopy() + if len(contents.Items) > i+lInt { + contents.SetContinue(base64.StdEncoding.EncodeToString([]byte(contents.Items[i+lInt].GetName()))) + } + if i > len(contents.Items) { + return contents, nil, nil + } + if i+lInt > len(contents.Items) { + contents.Items = contents.Items[i:] + return contents, nil, nil + } + contents.Items = contents.Items[i : i+lInt] + return contents, nil, nil +} + +func (m *mockStore) ByID(apiOp *types.APIRequest, schema *types.APISchema, id string) (*unstructured.Unstructured, []types.Warning, error) { + panic("not implemented") +} + +func (m *mockStore) Create(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject) (*unstructured.Unstructured, []types.Warning, error) { + panic("not implemented") +} + +func (m *mockStore) Update(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject, id string) (*unstructured.Unstructured, []types.Warning, error) { + panic("not implemented") +} + +func (m *mockStore) Delete(apiOp *types.APIRequest, schema *types.APISchema, id string) (*unstructured.Unstructured, []types.Warning, error) { + panic("not implemented") +} + +func (m *mockStore) Watch(apiOp *types.APIRequest, schema *types.APISchema, w types.WatchRequest) (chan watch.Event, error) { + panic("not implemented") +} + +type mockVersionedStore struct { + mockStore + versions []mockStore +} + +func (m *mockVersionedStore) List(apiOp *types.APIRequest, schema *types.APISchema) (*unstructured.UnstructuredList, []types.Warning, error) { + m.called++ + query, _ := url.ParseQuery(apiOp.Request.URL.RawQuery) + rv := len(m.versions) - 1 + if query.Get("resourceVersion") != "" { + rv, _ = strconv.Atoi(query.Get("resourceVersion")) + rv-- + } + l := query.Get("limit") + if l == "" { + return m.versions[rv].contents, nil, nil + } + i := 0 + if c := query.Get("continue"); c != "" { + start, _ := base64.StdEncoding.DecodeString(c) + for j, obj := range m.versions[rv].contents.Items { + if string(start) == obj.GetName() { + i = j + break + } + } + } + lInt, _ := strconv.Atoi(l) + contents := m.versions[rv].contents.DeepCopy() + if len(contents.Items) > i+lInt { + contents.SetContinue(base64.StdEncoding.EncodeToString([]byte(contents.Items[i+lInt].GetName()))) + } + if i > len(contents.Items) { + return contents, nil, nil + } + if i+lInt > len(contents.Items) { + contents.Items = contents.Items[i:] + return contents, nil, nil + } + contents.Items = contents.Items[i : i+lInt] + return contents, nil, nil +} + +type mockCache struct { + contents map[cacheKey]*unstructured.UnstructuredList +} + +var colorMap = map[string]string{ + "fuji": "pink", + "honeycrisp": "pink", + "granny-smith": "green", + "bramley": "green", + "crispin": "yellow", + "golden-delicious": "yellow", + "red-delicious": "red", +} + +func newRequest(query, username string) *types.APIRequest { + return &types.APIRequest{ + Request: (&http.Request{ + URL: &url.URL{ + Scheme: "https", + Host: "rancher", + Path: "/apples", + RawQuery: query, + }, + }).WithContext(request.WithUser(context.Background(), &user.DefaultInfo{ + Name: username, + Groups: []string{"system:authenticated"}, + })), + } +} + +type apple struct { + unstructured.Unstructured +} + +func newApple(name string) apple { + return apple{unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": name, + }, + "data": map[string]interface{}{ + "color": colorMap[name], + }, + }, + }} +} + +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: id, + Object: &a.Unstructured, + } +} + +func (a apple) with(data map[string]string) apple { + for k, v := range data { + a.Object["data"].(map[string]interface{})[k] = v + } + 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 +} + +func (m *mockAccessSetLookup) AccessFor(user user.Info) *accesscontrol.AccessSet { + userName := user.GetName() + access := getAccessID(userName, m.userRoles[0][userName]) + m.userRoles = m.userRoles[1:] + return &accesscontrol.AccessSet{ + ID: access, + } +} + +func (m *mockAccessSetLookup) PurgeUserData(_ string) { + panic("not implemented") +} + +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 generic.Indexer[*corev1.Namespace]) { + panic("not implemented") +} +func (m mockNamespaceCache) GetByIndex(indexName, key string) ([]*corev1.Namespace, error) { + panic("not implemented") +} diff --git a/pkg/stores/proxy/error_wrapper.go b/pkg/stores/proxy/error_wrapper.go index 4aa9fa42..4cc54167 100644 --- a/pkg/stores/proxy/error_wrapper.go +++ b/pkg/stores/proxy/error_wrapper.go @@ -3,43 +3,53 @@ package proxy import ( "github.com/rancher/apiserver/pkg/apierror" "github.com/rancher/apiserver/pkg/types" - "github.com/rancher/wrangler/pkg/schemas/validation" + "github.com/rancher/wrangler/v3/pkg/schemas/validation" "k8s.io/apimachinery/pkg/api/errors" ) -type errorStore struct { +// ErrorStore implements types.store with errors translated into APIErrors +type ErrorStore struct { types.Store } -func (e *errorStore) ByID(apiOp *types.APIRequest, schema *types.APISchema, id string) (types.APIObject, error) { +// NewErrorStore returns a store with errors translated into APIErrors +func NewErrorStore(s types.Store) *ErrorStore { + return &ErrorStore{Store: s} +} + +// ByID looks up a single object by its ID. +func (e *ErrorStore) ByID(apiOp *types.APIRequest, schema *types.APISchema, id string) (types.APIObject, error) { data, err := e.Store.ByID(apiOp, schema, id) return data, translateError(err) } -func (e *errorStore) List(apiOp *types.APIRequest, schema *types.APISchema) (types.APIObjectList, error) { +// List returns a list of resources. +func (e *ErrorStore) List(apiOp *types.APIRequest, schema *types.APISchema) (types.APIObjectList, error) { data, err := e.Store.List(apiOp, schema) return data, translateError(err) } -func (e *errorStore) Create(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject) (types.APIObject, error) { +// Create creates a single object in the store. +func (e *ErrorStore) Create(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject) (types.APIObject, error) { data, err := e.Store.Create(apiOp, schema, data) return data, translateError(err) - } -func (e *errorStore) Update(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject, id string) (types.APIObject, error) { +// Update updates a single object in the store. +func (e *ErrorStore) Update(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject, id string) (types.APIObject, error) { data, err := e.Store.Update(apiOp, schema, data, id) return data, translateError(err) - } -func (e *errorStore) Delete(apiOp *types.APIRequest, schema *types.APISchema, id string) (types.APIObject, error) { +// Delete deletes an object from a store. +func (e *ErrorStore) Delete(apiOp *types.APIRequest, schema *types.APISchema, id string) (types.APIObject, error) { data, err := e.Store.Delete(apiOp, schema, id) return data, translateError(err) } -func (e *errorStore) Watch(apiOp *types.APIRequest, schema *types.APISchema, wr types.WatchRequest) (chan types.APIEvent, error) { +// Watch returns a channel of events for a list or resource. +func (e *ErrorStore) Watch(apiOp *types.APIRequest, schema *types.APISchema, wr types.WatchRequest) (chan types.APIEvent, error) { data, err := e.Store.Watch(apiOp, schema, wr) return data, translateError(err) } diff --git a/pkg/stores/proxy/proxy_store.go b/pkg/stores/proxy/proxy_store.go index ce9fd994..d998a071 100644 --- a/pkg/stores/proxy/proxy_store.go +++ b/pkg/stores/proxy/proxy_store.go @@ -1,3 +1,4 @@ +// Package proxy implements the proxy store, which is responsible for interfacing directly with Kubernetes. package proxy import ( @@ -8,7 +9,6 @@ import ( "io/ioutil" "net/http" "os" - "reflect" "regexp" "strconv" @@ -18,9 +18,10 @@ import ( "github.com/rancher/steve/pkg/attributes" metricsStore "github.com/rancher/steve/pkg/stores/metrics" "github.com/rancher/steve/pkg/stores/partition" - "github.com/rancher/wrangler/pkg/data" - "github.com/rancher/wrangler/pkg/schemas/validation" - "github.com/rancher/wrangler/pkg/summary" + "github.com/rancher/wrangler/v3/pkg/data" + corecontrollers "github.com/rancher/wrangler/v3/pkg/generated/controllers/core/v1" + "github.com/rancher/wrangler/v3/pkg/schemas/validation" + "github.com/rancher/wrangler/v3/pkg/summary" "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" "k8s.io/apimachinery/pkg/api/meta" @@ -32,6 +33,7 @@ import ( "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" ) const watchTimeoutEnv = "CATTLE_WATCH_TIMEOUT_SECONDS" @@ -46,96 +48,88 @@ func init() { metav1.AddToGroupVersion(paramScheme, metav1.SchemeGroupVersion) } +// ClientGetter is a dynamic kubernetes client factory. type ClientGetter interface { IsImpersonating() bool K8sInterface(ctx *types.APIRequest) (kubernetes.Interface, error) AdminK8sInterface() (kubernetes.Interface, error) - Client(ctx *types.APIRequest, schema *types.APISchema, namespace string) (dynamic.ResourceInterface, error) - DynamicClient(ctx *types.APIRequest) (dynamic.Interface, error) - AdminClient(ctx *types.APIRequest, schema *types.APISchema, namespace string) (dynamic.ResourceInterface, error) - TableClient(ctx *types.APIRequest, schema *types.APISchema, namespace string) (dynamic.ResourceInterface, error) - TableAdminClient(ctx *types.APIRequest, schema *types.APISchema, namespace string) (dynamic.ResourceInterface, error) - TableClientForWatch(ctx *types.APIRequest, schema *types.APISchema, namespace string) (dynamic.ResourceInterface, error) - TableAdminClientForWatch(ctx *types.APIRequest, schema *types.APISchema, namespace string) (dynamic.ResourceInterface, error) + Client(ctx *types.APIRequest, schema *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) + DynamicClient(ctx *types.APIRequest, warningHandler rest.WarningHandler) (dynamic.Interface, error) + AdminClient(ctx *types.APIRequest, schema *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) + TableClient(ctx *types.APIRequest, schema *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) + TableAdminClient(ctx *types.APIRequest, schema *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) + TableClientForWatch(ctx *types.APIRequest, schema *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) + TableAdminClientForWatch(ctx *types.APIRequest, schema *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) } +// WarningBuffer holds warnings that may be returned from the kubernetes api +type WarningBuffer []types.Warning + +// HandleWarningHeader takes the components of a kubernetes warning header and stores them +func (w *WarningBuffer) HandleWarningHeader(code int, agent string, text string) { + *w = append(*w, types.Warning{ + Code: code, + Agent: agent, + Text: text, + }) +} + +// RelationshipNotifier is an interface for handling wrangler summary.Relationship events. type RelationshipNotifier interface { OnInboundRelationshipChange(ctx context.Context, schema *types.APISchema, namespace string) <-chan *summary.Relationship } +// Store implements partition.UnstructuredStore directly on top of kubernetes. type Store struct { clientGetter ClientGetter notifier RelationshipNotifier } -func NewProxyStore(clientGetter ClientGetter, notifier RelationshipNotifier, lookup accesscontrol.AccessSetLookup) types.Store { - return &errorStore{ - Store: &WatchRefresh{ - Store: &partition.Store{ - Partitioner: &rbacPartitioner{ - proxyStore: &Store{ - clientGetter: clientGetter, - notifier: notifier, +// NewProxyStore returns a wrapped types.Store. +func NewProxyStore(clientGetter ClientGetter, notifier RelationshipNotifier, lookup accesscontrol.AccessSetLookup, namespaceCache corecontrollers.NamespaceCache) types.Store { + return &ErrorStore{ + Store: &unformatterStore{ + Store: &WatchRefresh{ + Store: partition.NewStore( + &rbacPartitioner{ + proxyStore: &Store{ + clientGetter: clientGetter, + notifier: notifier, + }, }, - }, + lookup, + namespaceCache, + ), + asl: lookup, }, - asl: lookup, }, } } -func (s *Store) ByID(apiOp *types.APIRequest, schema *types.APISchema, id string) (types.APIObject, error) { - result, err := s.byID(apiOp, schema, apiOp.Namespace, id) - return toAPI(schema, result), err +// ByID looks up a single object by its ID. +func (s *Store) ByID(apiOp *types.APIRequest, schema *types.APISchema, id string) (*unstructured.Unstructured, []types.Warning, error) { + return s.byID(apiOp, schema, apiOp.Namespace, id) } func decodeParams(apiOp *types.APIRequest, target runtime.Object) error { return paramCodec.DecodeParameters(apiOp.Request.URL.Query(), metav1.SchemeGroupVersion, target) } -func toAPI(schema *types.APISchema, obj runtime.Object) types.APIObject { - if obj == nil || reflect.ValueOf(obj).IsNil() { - return types.APIObject{} - } - - if unstr, ok := obj.(*unstructured.Unstructured); ok { - obj = moveToUnderscore(unstr) - } - - apiObject := types.APIObject{ - Type: schema.ID, - Object: obj, - } - - m, err := meta.Accessor(obj) +func (s *Store) byID(apiOp *types.APIRequest, schema *types.APISchema, namespace, id string) (*unstructured.Unstructured, []types.Warning, error) { + buffer := WarningBuffer{} + k8sClient, err := metricsStore.Wrap(s.clientGetter.TableClient(apiOp, schema, namespace, &buffer)) if err != nil { - return apiObject - } - - id := m.GetName() - ns := m.GetNamespace() - if ns != "" { - id = fmt.Sprintf("%s/%s", ns, id) - } - - apiObject.ID = id - return apiObject -} - -func (s *Store) byID(apiOp *types.APIRequest, schema *types.APISchema, namespace, id string) (*unstructured.Unstructured, error) { - k8sClient, err := metricsStore.Wrap(s.clientGetter.TableClient(apiOp, schema, namespace)) - if err != nil { - return nil, err + return nil, nil, err } opts := metav1.GetOptions{} if err := decodeParams(apiOp, &opts); err != nil { - return nil, err + return nil, nil, err } obj, err := k8sClient.Get(apiOp, id, opts) rowToObject(obj) - return obj, err + return obj, buffer, err } func moveFromUnderscore(obj map[string]interface{}) map[string]interface{} { @@ -153,22 +147,6 @@ func moveFromUnderscore(obj map[string]interface{}) map[string]interface{} { return obj } -func moveToUnderscore(obj *unstructured.Unstructured) *unstructured.Unstructured { - if obj == nil { - return nil - } - - for k := range types.ReservedFields { - v, ok := obj.Object[k] - if ok { - delete(obj.Object, k) - obj.Object["_"+k] = v - } - } - - return obj -} - func rowToObject(obj *unstructured.Unstructured) { if obj == nil { return @@ -219,76 +197,79 @@ func tableToObjects(obj map[string]interface{}) []unstructured.Unstructured { return result } -func (s *Store) ByNames(apiOp *types.APIRequest, schema *types.APISchema, names sets.String) (types.APIObjectList, error) { +// ByNames filters a list of objects by an allowed set of names. +// In plain kubernetes, if a user has permission to 'list' or 'watch' a defined set of resource names, +// performing the list or watch will result in a Forbidden error, because the user does not have permission +// to list *all* resources. +// With this filter, the request can be performed successfully, and only the allowed resources will +// be returned in the list. +func (s *Store) ByNames(apiOp *types.APIRequest, schema *types.APISchema, names sets.String) (*unstructured.UnstructuredList, []types.Warning, error) { if apiOp.Namespace == "*" { - // This happens when you grant namespaced objects with "get" by name in a clusterrolebinding. We will treat - // this as an invalid situation instead of listing all objects in the cluster and filtering by name. - return types.APIObjectList{}, nil + // This happens when you grant namespaced objects with "get" or "list "by name in a clusterrolebinding. + // We will treat this as an invalid situation instead of listing all objects in the cluster + // and filtering by name. + return &unstructured.UnstructuredList{}, nil, nil } - - adminClient, err := s.clientGetter.TableAdminClient(apiOp, schema, apiOp.Namespace) + buffer := WarningBuffer{} + adminClient, err := s.clientGetter.TableAdminClient(apiOp, schema, apiOp.Namespace, &buffer) if err != nil { - return types.APIObjectList{}, err + return nil, nil, err } objs, err := s.list(apiOp, schema, adminClient) if err != nil { - return types.APIObjectList{}, err + return nil, nil, err } - var filtered []types.APIObject - for _, obj := range objs.Objects { - if names.Has(obj.Name()) { + var filtered []unstructured.Unstructured + for _, obj := range objs.Items { + if names.Has(obj.GetName()) { filtered = append(filtered, obj) } } - objs.Objects = filtered - return objs, nil + objs.Items = filtered + return objs, buffer, nil } -func (s *Store) List(apiOp *types.APIRequest, schema *types.APISchema) (types.APIObjectList, error) { - client, err := s.clientGetter.TableClient(apiOp, schema, apiOp.Namespace) +// List returns an unstructured list of resources. +func (s *Store) List(apiOp *types.APIRequest, schema *types.APISchema) (*unstructured.UnstructuredList, []types.Warning, error) { + buffer := WarningBuffer{} + client, err := s.clientGetter.TableClient(apiOp, schema, apiOp.Namespace, &buffer) if err != nil { - return types.APIObjectList{}, err + return nil, nil, err } - return s.list(apiOp, schema, client) + result, err := s.list(apiOp, schema, client) + return result, buffer, err } -func (s *Store) list(apiOp *types.APIRequest, schema *types.APISchema, client dynamic.ResourceInterface) (types.APIObjectList, error) { +func (s *Store) list(apiOp *types.APIRequest, schema *types.APISchema, client dynamic.ResourceInterface) (*unstructured.UnstructuredList, error) { opts := metav1.ListOptions{} if err := decodeParams(apiOp, &opts); err != nil { - return types.APIObjectList{}, nil + return nil, nil } k8sClient, _ := metricsStore.Wrap(client, nil) resultList, err := k8sClient.List(apiOp, opts) if err != nil { - return types.APIObjectList{}, err + return nil, err } tableToList(resultList) - result := types.APIObjectList{ - Revision: resultList.GetResourceVersion(), - Continue: resultList.GetContinue(), - } - - for i := range resultList.Items { - result.Objects = append(result.Objects, toAPI(schema, &resultList.Items[i])) - } - - return result, nil + return resultList, nil } -func returnErr(err error, c chan types.APIEvent) { - c <- types.APIEvent{ - Name: "resource.error", - Error: err, +func returnErr(err error, c chan watch.Event) { + c <- watch.Event{ + Type: watch.Error, + Object: &metav1.Status{ + Message: err.Error(), + }, } } -func (s *Store) listAndWatch(apiOp *types.APIRequest, client dynamic.ResourceInterface, schema *types.APISchema, w types.WatchRequest, result chan types.APIEvent) { +func (s *Store) listAndWatch(apiOp *types.APIRequest, client dynamic.ResourceInterface, schema *types.APISchema, w types.WatchRequest, result chan watch.Event) { rev := w.Revision if rev == "-1" || rev == "0" { rev = "" @@ -328,11 +309,11 @@ func (s *Store) listAndWatch(apiOp *types.APIRequest, client dynamic.ResourceInt if s.notifier != nil { eg.Go(func() error { for rel := range s.notifier.OnInboundRelationshipChange(ctx, schema, apiOp.Namespace) { - obj, err := s.byID(apiOp, schema, rel.Namespace, rel.Name) + obj, _, err := s.byID(apiOp, schema, rel.Namespace, rel.Name) if err == nil { - result <- s.toAPIEvent(apiOp, schema, watch.Modified, obj) + rowToObject(obj) + result <- watch.Event{Type: watch.Modified, Object: obj} } else { - logrus.Debugf("notifier watch error: %v", err) returnErr(errors.Wrapf(err, "notifier watch error: %v", err), result) } } @@ -344,14 +325,16 @@ func (s *Store) listAndWatch(apiOp *types.APIRequest, client dynamic.ResourceInt for event := range watcher.ResultChan() { if event.Type == watch.Error { if status, ok := event.Object.(*metav1.Status); ok { - logrus.Debugf("event watch error: %s", status.Message) returnErr(fmt.Errorf("event watch error: %s", status.Message), result) } else { logrus.Debugf("event watch error: could not decode event object %T", event.Object) } continue } - result <- s.toAPIEvent(apiOp, schema, event.Type, event.Object) + if unstr, ok := event.Object.(*unstructured.Unstructured); ok { + rowToObject(unstr) + } + result <- event } return fmt.Errorf("closed") }) @@ -360,8 +343,15 @@ func (s *Store) listAndWatch(apiOp *types.APIRequest, client dynamic.ResourceInt return } -func (s *Store) WatchNames(apiOp *types.APIRequest, schema *types.APISchema, w types.WatchRequest, names sets.String) (chan types.APIEvent, error) { - adminClient, err := s.clientGetter.TableAdminClientForWatch(apiOp, schema, apiOp.Namespace) +// WatchNames returns a channel of events filtered by an allowed set of names. +// In plain kubernetes, if a user has permission to 'list' or 'watch' a defined set of resource names, +// performing the list or watch will result in a Forbidden error, because the user does not have permission +// to list *all* resources. +// With this filter, the request can be performed successfully, and only the allowed resources will +// be returned in watch. +func (s *Store) WatchNames(apiOp *types.APIRequest, schema *types.APISchema, w types.WatchRequest, names sets.String) (chan watch.Event, error) { + buffer := &WarningBuffer{} + adminClient, err := s.clientGetter.TableAdminClientForWatch(apiOp, schema, apiOp.Namespace, buffer) if err != nil { return nil, err } @@ -370,11 +360,27 @@ func (s *Store) WatchNames(apiOp *types.APIRequest, schema *types.APISchema, w t return nil, err } - result := make(chan types.APIEvent) + result := make(chan watch.Event) go func() { defer close(result) for item := range c { - if item.Error == nil && names.Has(item.Object.Name()) { + if item.Type == watch.Error { + if status, ok := item.Object.(*metav1.Status); ok { + logrus.Debugf("WatchNames received error: %s", status.Message) + } else { + logrus.Debugf("WatchNames received error: %v", item) + } + result <- item + continue + } + + m, err := meta.Accessor(item.Object) + if err != nil { + logrus.Debugf("WatchNames cannot process unexpected object: %s", err) + continue + } + + if names.Has(m.GetName()) { result <- item } } @@ -383,16 +389,18 @@ func (s *Store) WatchNames(apiOp *types.APIRequest, schema *types.APISchema, w t return result, nil } -func (s *Store) Watch(apiOp *types.APIRequest, schema *types.APISchema, w types.WatchRequest) (chan types.APIEvent, error) { - client, err := s.clientGetter.TableClientForWatch(apiOp, schema, apiOp.Namespace) +// Watch returns a channel of events for a list or resource. +func (s *Store) Watch(apiOp *types.APIRequest, schema *types.APISchema, w types.WatchRequest) (chan watch.Event, error) { + buffer := &WarningBuffer{} + client, err := s.clientGetter.TableClientForWatch(apiOp, schema, apiOp.Namespace, buffer) if err != nil { return nil, err } return s.watch(apiOp, schema, w, client) } -func (s *Store) watch(apiOp *types.APIRequest, schema *types.APISchema, w types.WatchRequest, client dynamic.ResourceInterface) (chan types.APIEvent, error) { - result := make(chan types.APIEvent) +func (s *Store) watch(apiOp *types.APIRequest, schema *types.APISchema, w types.WatchRequest, client dynamic.ResourceInterface) (chan watch.Event, error) { + result := make(chan watch.Event) go func() { s.listAndWatch(apiOp, client, schema, w, result) logrus.Debugf("closing watcher for %s", schema.ID) @@ -401,34 +409,8 @@ func (s *Store) watch(apiOp *types.APIRequest, schema *types.APISchema, w types. return result, nil } -func (s *Store) toAPIEvent(apiOp *types.APIRequest, schema *types.APISchema, et watch.EventType, obj runtime.Object) types.APIEvent { - name := types.ChangeAPIEvent - switch et { - case watch.Deleted: - name = types.RemoveAPIEvent - case watch.Added: - name = types.CreateAPIEvent - } - - if unstr, ok := obj.(*unstructured.Unstructured); ok { - rowToObject(unstr) - } - - event := types.APIEvent{ - Name: name, - Object: toAPI(schema, obj), - } - - m, err := meta.Accessor(obj) - if err != nil { - return event - } - - event.Revision = m.GetResourceVersion() - return event -} - -func (s *Store) Create(apiOp *types.APIRequest, schema *types.APISchema, params types.APIObject) (types.APIObject, error) { +// Create creates a single object in the store. +func (s *Store) Create(apiOp *types.APIRequest, schema *types.APISchema, params types.APIObject) (*unstructured.Unstructured, []types.Warning, error) { var ( resp *unstructured.Unstructured ) @@ -452,38 +434,40 @@ func (s *Store) Create(apiOp *types.APIRequest, schema *types.APISchema, params gvk := attributes.GVK(schema) input["apiVersion"], input["kind"] = gvk.ToAPIVersionAndKind() - k8sClient, err := metricsStore.Wrap(s.clientGetter.TableClient(apiOp, schema, ns)) + buffer := WarningBuffer{} + k8sClient, err := metricsStore.Wrap(s.clientGetter.TableClient(apiOp, schema, ns, &buffer)) if err != nil { - return types.APIObject{}, err + return nil, nil, err } opts := metav1.CreateOptions{} if err := decodeParams(apiOp, &opts); err != nil { - return types.APIObject{}, err + return nil, nil, err } resp, err = k8sClient.Create(apiOp, &unstructured.Unstructured{Object: input}, opts) rowToObject(resp) - apiObject := toAPI(schema, resp) - return apiObject, err + return resp, buffer, err } -func (s *Store) Update(apiOp *types.APIRequest, schema *types.APISchema, params types.APIObject, id string) (types.APIObject, error) { +// Update updates a single object in the store. +func (s *Store) Update(apiOp *types.APIRequest, schema *types.APISchema, params types.APIObject, id string) (*unstructured.Unstructured, []types.Warning, error) { var ( err error input = params.Data() ) ns := types.Namespace(input) - k8sClient, err := metricsStore.Wrap(s.clientGetter.TableClient(apiOp, schema, ns)) + buffer := WarningBuffer{} + k8sClient, err := metricsStore.Wrap(s.clientGetter.TableClient(apiOp, schema, ns, &buffer)) if err != nil { - return types.APIObject{}, err + return nil, nil, err } if apiOp.Method == http.MethodPatch { bytes, err := ioutil.ReadAll(io.LimitReader(apiOp.Request.Body, 2<<20)) if err != nil { - return types.APIObject{}, err + return nil, nil, err } pType := apitypes.StrategicMergePatchType @@ -493,69 +477,71 @@ func (s *Store) Update(apiOp *types.APIRequest, schema *types.APISchema, params opts := metav1.PatchOptions{} if err := decodeParams(apiOp, &opts); err != nil { - return types.APIObject{}, err + return nil, nil, err } if pType == apitypes.StrategicMergePatchType { data := map[string]interface{}{} if err := json.Unmarshal(bytes, &data); err != nil { - return types.APIObject{}, err + return nil, nil, err } data = moveFromUnderscore(data) bytes, err = json.Marshal(data) if err != nil { - return types.APIObject{}, err + return nil, nil, err } } resp, err := k8sClient.Patch(apiOp, id, pType, bytes, opts) if err != nil { - return types.APIObject{}, err + return nil, nil, err } - return toAPI(schema, resp), nil + return resp, buffer, nil } resourceVersion := input.String("metadata", "resourceVersion") if resourceVersion == "" { - return types.APIObject{}, fmt.Errorf("metadata.resourceVersion is required for update") + return nil, nil, fmt.Errorf("metadata.resourceVersion is required for update") } opts := metav1.UpdateOptions{} if err := decodeParams(apiOp, &opts); err != nil { - return types.APIObject{}, err + return nil, nil, err } resp, err := k8sClient.Update(apiOp, &unstructured.Unstructured{Object: moveFromUnderscore(input)}, metav1.UpdateOptions{}) if err != nil { - return types.APIObject{}, err + return nil, nil, err } rowToObject(resp) - return toAPI(schema, resp), nil + return resp, buffer, nil } -func (s *Store) Delete(apiOp *types.APIRequest, schema *types.APISchema, id string) (types.APIObject, error) { +// Delete deletes an object from a store. +func (s *Store) Delete(apiOp *types.APIRequest, schema *types.APISchema, id string) (*unstructured.Unstructured, []types.Warning, error) { opts := metav1.DeleteOptions{} if err := decodeParams(apiOp, &opts); err != nil { - return types.APIObject{}, nil + return nil, nil, nil } - k8sClient, err := metricsStore.Wrap(s.clientGetter.TableClient(apiOp, schema, apiOp.Namespace)) + buffer := WarningBuffer{} + k8sClient, err := metricsStore.Wrap(s.clientGetter.TableClient(apiOp, schema, apiOp.Namespace, &buffer)) if err != nil { - return types.APIObject{}, err + return nil, nil, err } if err := k8sClient.Delete(apiOp, id, opts); err != nil { - return types.APIObject{}, err + return nil, nil, err } - obj, err := s.byID(apiOp, schema, apiOp.Namespace, id) + obj, _, err := s.byID(apiOp, schema, apiOp.Namespace, id) if err != nil { // ignore lookup error - return types.APIObject{}, validation.ErrorCode{ + return nil, nil, validation.ErrorCode{ Status: http.StatusNoContent, } } - return toAPI(schema, obj), nil + return obj, buffer, nil } diff --git a/pkg/stores/proxy/proxy_store_test.go b/pkg/stores/proxy/proxy_store_test.go new file mode 100644 index 00000000..ddfc376d --- /dev/null +++ b/pkg/stores/proxy/proxy_store_test.go @@ -0,0 +1,123 @@ +package proxy + +import ( + "net/http" + "strings" + "testing" + "time" + + "github.com/pkg/errors" + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/steve/pkg/client" + "github.com/rancher/wrangler/v3/pkg/schemas" + "github.com/stretchr/testify/assert" + "golang.org/x/sync/errgroup" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + schema2 "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/rest" + clientgotesting "k8s.io/client-go/testing" +) + +var c *watch.FakeWatcher + +type testFactory struct { + *client.Factory + + fakeClient *fake.FakeDynamicClient +} + +func TestWatchNamesErrReceive(t *testing.T) { + testClientFactory, err := client.NewFactory(&rest.Config{}, false) + assert.Nil(t, err) + + fakeClient := fake.NewSimpleDynamicClient(runtime.NewScheme()) + c = watch.NewFakeWithChanSize(5, true) + defer c.Stop() + errMsgsToSend := []string{"err1", "err2", "err3"} + c.Add(&v1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "testsecret1"}}) + for index := range errMsgsToSend { + c.Error(&metav1.Status{ + Message: errMsgsToSend[index], + }) + } + c.Add(&v1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "testsecret2"}}) + fakeClient.PrependWatchReactor("*", func(action clientgotesting.Action) (handled bool, ret watch.Interface, err error) { + return true, c, nil + }) + testStore := Store{ + clientGetter: &testFactory{Factory: testClientFactory, + fakeClient: fakeClient, + }, + } + apiSchema := &types.APISchema{Schema: &schemas.Schema{Attributes: map[string]interface{}{"table": "something"}}} + wc, err := testStore.WatchNames(&types.APIRequest{Namespace: "", Schema: apiSchema, Request: &http.Request{}}, apiSchema, types.WatchRequest{}, sets.NewString("testsecret1", "testsecret2")) + assert.Nil(t, err) + + eg := errgroup.Group{} + eg.Go(func() error { return receiveUntil(wc, 5*time.Second) }) + + err = eg.Wait() + assert.Nil(t, err) + + assert.Equal(t, 0, len(c.ResultChan()), "Expected all secrets to have been received") +} + +func TestByNames(t *testing.T) { + s := Store{} + apiSchema := &types.APISchema{Schema: &schemas.Schema{}} + apiOp := &types.APIRequest{Namespace: "*", Schema: apiSchema, Request: &http.Request{}} + names := sets.NewString("some-resource", "some-other-resource") + result, warn, err := s.ByNames(apiOp, apiSchema, names) + assert.NotNil(t, result) + assert.Len(t, result.Items, 0) + assert.Nil(t, err) + assert.Nil(t, warn) +} + +func (t *testFactory) TableAdminClientForWatch(ctx *types.APIRequest, schema *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) { + return t.fakeClient.Resource(schema2.GroupVersionResource{}), nil +} + +func receiveUntil(wc chan watch.Event, d time.Duration) error { + timer := time.NewTicker(d) + defer timer.Stop() + secretNames := []string{"testsecret1", "testsecret2"} + errMsgs := []string{"err1", "err2", "err3"} + for { + select { + case event, ok := <-wc: + if !ok { + return errors.New("watch chan should not have been closed") + } + + if event.Type == watch.Error { + status, ok := event.Object.(*metav1.Status) + if !ok { + continue + } + if strings.HasSuffix(status.Message, errMsgs[0]) { + errMsgs = errMsgs[1:] + } + } + secret, ok := event.Object.(*v1.Secret) + if !ok { + continue + } + if secret.Name == secretNames[0] { + secretNames = secretNames[1:] + } + if len(secretNames) == 0 && len(errMsgs) == 0 { + return nil + } + continue + case <-timer.C: + return errors.New("timed out waiting to receiving objects from chan") + } + } +} diff --git a/pkg/stores/proxy/rbac_store.go b/pkg/stores/proxy/rbac_store.go index a5fc4167..f7e2294b 100644 --- a/pkg/stores/proxy/rbac_store.go +++ b/pkg/stores/proxy/rbac_store.go @@ -1,17 +1,17 @@ package proxy import ( - "context" "fmt" - "net/http" "sort" "github.com/rancher/apiserver/pkg/types" "github.com/rancher/steve/pkg/accesscontrol" "github.com/rancher/steve/pkg/attributes" "github.com/rancher/steve/pkg/stores/partition" - "github.com/rancher/wrangler/pkg/kv" + "github.com/rancher/wrangler/v3/pkg/kv" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/watch" ) var ( @@ -20,19 +20,7 @@ var ( } ) -type filterKey struct{} - -func AddNamespaceConstraint(req *http.Request, names ...string) *http.Request { - set := sets.NewString(names...) - ctx := context.WithValue(req.Context(), filterKey{}, set) - return req.WithContext(ctx) -} - -func getNamespaceConstraint(req *http.Request) (sets.String, bool) { - set, ok := req.Context().Value(filterKey{}).(sets.String) - return set, ok -} - +// Partition is an implementation of the partition.Partition interface that uses RBAC to determine how a set of resources should be segregated and accessed. type Partition struct { Namespace string All bool @@ -40,14 +28,18 @@ type Partition struct { Names sets.String } +// Name returns the name of the partition, which for this type is the namespace. func (p Partition) Name() string { return p.Namespace } +// rbacPartitioner is an implementation of the partition.Partioner interface. type rbacPartitioner struct { proxyStore *Store } +// Lookup returns the default passthrough partition which is used only for retrieving single resources. +// Listing or watching resources require custom partitions. func (p *rbacPartitioner) Lookup(apiOp *types.APIRequest, schema *types.APISchema, verb, id string) (partition.Partition, error) { switch verb { case "create": @@ -63,6 +55,9 @@ func (p *rbacPartitioner) Lookup(apiOp *types.APIRequest, schema *types.APISchem } } +// All returns a slice of partitions applicable to the API schema and the user's access level. +// For watching individual resources or for blanket access permissions, it returns the passthrough partition. +// For more granular permissions, it returns a slice of partitions matching an allowed namespace or resource names. func (p *rbacPartitioner) All(apiOp *types.APIRequest, schema *types.APISchema, verb, id string) ([]partition.Partition, error) { switch verb { case "list": @@ -92,7 +87,8 @@ func (p *rbacPartitioner) All(apiOp *types.APIRequest, schema *types.APISchema, } } -func (p *rbacPartitioner) Store(apiOp *types.APIRequest, partition partition.Partition) (types.Store, error) { +// Store returns an UnstructuredStore suited to listing and watching resources by partition. +func (p *rbacPartitioner) Store(apiOp *types.APIRequest, partition partition.Partition) (partition.UnstructuredStore, error) { return &byNameOrNamespaceStore{ Store: p.proxyStore, partition: partition.(Partition), @@ -104,7 +100,8 @@ type byNameOrNamespaceStore struct { partition Partition } -func (b *byNameOrNamespaceStore) List(apiOp *types.APIRequest, schema *types.APISchema) (types.APIObjectList, error) { +// List returns a list of resources by partition. +func (b *byNameOrNamespaceStore) List(apiOp *types.APIRequest, schema *types.APISchema) (*unstructured.UnstructuredList, []types.Warning, error) { if b.partition.Passthrough { return b.Store.List(apiOp, schema) } @@ -116,7 +113,8 @@ func (b *byNameOrNamespaceStore) List(apiOp *types.APIRequest, schema *types.API return b.Store.ByNames(apiOp, schema, b.partition.Names) } -func (b *byNameOrNamespaceStore) Watch(apiOp *types.APIRequest, schema *types.APISchema, wr types.WatchRequest) (chan types.APIEvent, error) { +// Watch returns a channel of resources by partition. +func (b *byNameOrNamespaceStore) Watch(apiOp *types.APIRequest, schema *types.APISchema, wr types.WatchRequest) (chan watch.Event, error) { if b.partition.Passthrough { return b.Store.Watch(apiOp, schema, wr) } @@ -128,35 +126,9 @@ func (b *byNameOrNamespaceStore) Watch(apiOp *types.APIRequest, schema *types.AP return b.Store.WatchNames(apiOp, schema, wr, b.partition.Names) } +// isPassthrough determines whether a request can be passed through directly to the underlying store +// or if the results need to be partitioned by namespace and name based on the requester's access. func isPassthrough(apiOp *types.APIRequest, schema *types.APISchema, verb string) ([]partition.Partition, bool) { - partitions, passthrough := isPassthroughUnconstrained(apiOp, schema, verb) - namespaces, ok := getNamespaceConstraint(apiOp.Request) - if !ok { - return partitions, passthrough - } - - var result []partition.Partition - - if passthrough { - for namespace := range namespaces { - result = append(result, Partition{ - Namespace: namespace, - All: true, - }) - } - return result, false - } - - for _, partition := range partitions { - if namespaces.Has(partition.Name()) { - result = append(result, partition) - } - } - - return result, false -} - -func isPassthroughUnconstrained(apiOp *types.APIRequest, schema *types.APISchema, verb string) ([]partition.Partition, bool) { accessListByVerb, _ := attributes.Access(schema).(accesscontrol.AccessListByVerb) if accessListByVerb.All(verb) { return nil, true @@ -166,14 +138,13 @@ func isPassthroughUnconstrained(apiOp *types.APIRequest, schema *types.APISchema if apiOp.Namespace != "" { if resources[apiOp.Namespace].All { return nil, true - } else { - return []partition.Partition{ - Partition{ - Namespace: apiOp.Namespace, - Names: resources[apiOp.Namespace].Names, - }, - }, false } + return []partition.Partition{ + Partition{ + Namespace: apiOp.Namespace, + Names: resources[apiOp.Namespace].Names, + }, + }, false } var result []partition.Partition diff --git a/pkg/stores/proxy/rbac_store_test.go b/pkg/stores/proxy/rbac_store_test.go new file mode 100644 index 00000000..ee19c9fe --- /dev/null +++ b/pkg/stores/proxy/rbac_store_test.go @@ -0,0 +1,252 @@ +package proxy + +import ( + "testing" + + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/steve/pkg/accesscontrol" + "github.com/rancher/steve/pkg/stores/partition" + "github.com/rancher/wrangler/v3/pkg/schemas" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/util/sets" +) + +func TestAll(t *testing.T) { + tests := []struct { + name string + apiOp *types.APIRequest + id string + schema *types.APISchema + wantPartitions []partition.Partition + }{ + { + name: "all passthrough", + apiOp: &types.APIRequest{}, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "foo", + Attributes: map[string]interface{}{ + "access": accesscontrol.AccessListByVerb{ + "list": accesscontrol.AccessList{ + accesscontrol.Access{ + Namespace: "*", + ResourceName: "*", + }, + }, + }, + }, + }, + }, + wantPartitions: passthroughPartitions, + }, + { + name: "global access for global request", + apiOp: &types.APIRequest{}, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "foo", + Attributes: map[string]interface{}{ + "access": accesscontrol.AccessListByVerb{ + "list": accesscontrol.AccessList{ + accesscontrol.Access{ + Namespace: "*", + ResourceName: "r1", + }, + accesscontrol.Access{ + Namespace: "*", + ResourceName: "r2", + }, + }, + }, + }, + }, + }, + wantPartitions: []partition.Partition{ + Partition{ + Names: sets.NewString("r1", "r2"), + }, + }, + }, + { + name: "namespace access for global request", + apiOp: &types.APIRequest{}, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "foo", + Attributes: map[string]interface{}{ + "namespaced": true, + "access": accesscontrol.AccessListByVerb{ + "list": accesscontrol.AccessList{ + accesscontrol.Access{ + Namespace: "n1", + ResourceName: "*", + }, + accesscontrol.Access{ + Namespace: "n2", + ResourceName: "*", + }, + }, + }, + }, + }, + }, + wantPartitions: []partition.Partition{ + Partition{ + Namespace: "n1", + All: true, + }, + Partition{ + Namespace: "n2", + All: true, + }, + }, + }, + { + name: "namespace access for namespaced request", + apiOp: &types.APIRequest{ + Namespace: "n1", + }, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "foo", + Attributes: map[string]interface{}{ + "namespaced": true, + "access": accesscontrol.AccessListByVerb{ + "list": accesscontrol.AccessList{ + accesscontrol.Access{ + Namespace: "n1", + ResourceName: "*", + }, + }, + }, + }, + }, + }, + wantPartitions: passthroughPartitions, + }, + { + // we still get a partition even if there is no access to it, it will be rejected by the API server later + name: "namespace access for invalid namespaced request", + apiOp: &types.APIRequest{ + Namespace: "n2", + }, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "foo", + Attributes: map[string]interface{}{ + "namespaced": true, + "access": accesscontrol.AccessListByVerb{ + "list": accesscontrol.AccessList{ + accesscontrol.Access{ + Namespace: "n1", + ResourceName: "*", + }, + }, + }, + }, + }, + }, + wantPartitions: []partition.Partition{ + Partition{ + Namespace: "n2", + }, + }, + }, + { + name: "by names access for global request", + apiOp: &types.APIRequest{}, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "foo", + Attributes: map[string]interface{}{ + "namespaced": true, + "access": accesscontrol.AccessListByVerb{ + "list": accesscontrol.AccessList{ + accesscontrol.Access{ + Namespace: "n1", + ResourceName: "r1", + }, + accesscontrol.Access{ + Namespace: "n1", + ResourceName: "r2", + }, + accesscontrol.Access{ + Namespace: "n2", + ResourceName: "r1", + }, + }, + }, + }, + }, + }, + wantPartitions: []partition.Partition{ + Partition{ + Namespace: "n1", + Names: sets.NewString("r1", "r2"), + }, + Partition{ + Namespace: "n2", + Names: sets.NewString("r1"), + }, + }, + }, + { + name: "by names access for namespaced request", + apiOp: &types.APIRequest{ + Namespace: "n1", + }, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "foo", + Attributes: map[string]interface{}{ + "namespaced": true, + "access": accesscontrol.AccessListByVerb{ + "list": accesscontrol.AccessList{ + accesscontrol.Access{ + Namespace: "n1", + ResourceName: "r1", + }, + accesscontrol.Access{ + Namespace: "n2", + ResourceName: "r1", + }, + }, + }, + }, + }, + }, + wantPartitions: []partition.Partition{ + Partition{ + Namespace: "n1", + Names: sets.NewString("r1"), + }, + }, + }, + { + name: "by id", + apiOp: &types.APIRequest{}, + id: "n1/r1", + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "foo", + }, + }, + wantPartitions: []partition.Partition{ + Partition{ + Namespace: "n1", + Names: sets.NewString("r1"), + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + partitioner := rbacPartitioner{} + verb := "list" + gotPartitions, gotErr := partitioner.All(test.apiOp, test.schema, verb, test.id) + assert.Nil(t, gotErr) + assert.Equal(t, test.wantPartitions, gotPartitions) + }) + } +} diff --git a/pkg/stores/proxy/unformatter.go b/pkg/stores/proxy/unformatter.go new file mode 100644 index 00000000..270c9178 --- /dev/null +++ b/pkg/stores/proxy/unformatter.go @@ -0,0 +1,71 @@ +package proxy + +import ( + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/wrangler/v3/pkg/data" + "github.com/rancher/wrangler/v3/pkg/data/convert" +) + +// unformatterStore removes fields added by the formatter that kubernetes cannot recognize. +type unformatterStore struct { + types.Store +} + +// NewUnformatterStore returns a store which removes fields added by the formatter that kubernetes cannot recognize. +func NewUnformatterStore(s types.Store) types.Store { + return &unformatterStore{Store: s} +} + +// ByID looks up a single object by its ID. +func (u *unformatterStore) ByID(apiOp *types.APIRequest, schema *types.APISchema, id string) (types.APIObject, error) { + return u.Store.ByID(apiOp, schema, id) +} + +// List returns a list of resources. +func (u *unformatterStore) List(apiOp *types.APIRequest, schema *types.APISchema) (types.APIObjectList, error) { + return u.Store.List(apiOp, schema) +} + +// Create creates a single object in the store. +func (u *unformatterStore) Create(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject) (types.APIObject, error) { + return u.Store.Create(apiOp, schema, data) +} + +// Update updates a single object in the store. +func (u *unformatterStore) Update(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject, id string) (types.APIObject, error) { + data = unformat(data) + return u.Store.Update(apiOp, schema, data, id) +} + +// Delete deletes an object from a store. +func (u *unformatterStore) Delete(apiOp *types.APIRequest, schema *types.APISchema, id string) (types.APIObject, error) { + return u.Store.Delete(apiOp, schema, id) + +} + +// Watch returns a channel of events for a list or resource. +func (u *unformatterStore) Watch(apiOp *types.APIRequest, schema *types.APISchema, wr types.WatchRequest) (chan types.APIEvent, error) { + return u.Store.Watch(apiOp, schema, wr) +} + +func unformat(obj types.APIObject) types.APIObject { + unst, ok := obj.Object.(map[string]interface{}) + if !ok { + return obj + } + data.RemoveValue(unst, "metadata", "fields") + data.RemoveValue(unst, "metadata", "relationships") + data.RemoveValue(unst, "metadata", "state") + conditions, ok := data.GetValue(unst, "status", "conditions") + if ok { + conditionsSlice := convert.ToMapSlice(conditions) + for i := range conditionsSlice { + data.RemoveValue(conditionsSlice[i], "error") + data.RemoveValue(conditionsSlice[i], "transitioning") + data.RemoveValue(conditionsSlice[i], "lastUpdateTime") + } + data.PutValue(unst, conditionsSlice, "status", "conditions") + } + obj.Object = unst + return obj +} diff --git a/pkg/stores/proxy/unformatter_test.go b/pkg/stores/proxy/unformatter_test.go new file mode 100644 index 00000000..da559a1e --- /dev/null +++ b/pkg/stores/proxy/unformatter_test.go @@ -0,0 +1,112 @@ +package proxy + +import ( + "testing" + + "github.com/rancher/apiserver/pkg/types" + "github.com/stretchr/testify/assert" +) + +func Test_unformat(t *testing.T) { + tests := []struct { + name string + obj types.APIObject + want types.APIObject + }{ + { + name: "noop", + obj: types.APIObject{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "noop", + }, + }, + }, + want: types.APIObject{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "noop", + }, + }, + }, + }, + { + name: "remove fields", + obj: types.APIObject{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "foo", + "fields": []string{ + "name", + "address", + "phonenumber", + }, + "relationships": []map[string]interface{}{ + { + "toId": "bar", + "rel": "uses", + }, + }, + "state": map[string]interface{}{ + "error": false, + }, + }, + "status": map[string]interface{}{ + "conditions": []map[string]interface{}{ + { + "type": "Ready", + "status": "True", + "lastUpdateTime": "a minute ago", + "transitioning": false, + "error": false, + }, + { + "type": "Initialized", + "status": "True", + "lastUpdateTime": "yesterday", + "transitioning": false, + "error": false, + }, + }, + }, + }, + }, + want: types.APIObject{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "foo", + }, + "status": map[string]interface{}{ + "conditions": []map[string]interface{}{ + { + "type": "Ready", + "status": "True", + }, + { + "type": "Initialized", + "status": "True", + }, + }, + }, + }, + }, + }, + { + name: "unrecognized object", + obj: types.APIObject{ + Object: "object", + }, + want: types.APIObject{ + Object: "object", + }, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + got := unformat(test.obj) + assert.Equal(t, test.want, got) + }) + } +} diff --git a/pkg/stores/proxy/watch_refresh.go b/pkg/stores/proxy/watch_refresh.go index 7674a16f..f6e3368e 100644 --- a/pkg/stores/proxy/watch_refresh.go +++ b/pkg/stores/proxy/watch_refresh.go @@ -9,11 +9,21 @@ import ( "k8s.io/apiserver/pkg/endpoints/request" ) +// WatchRefresh implements types.Store with awareness of changes to the requester's access. type WatchRefresh struct { types.Store asl accesscontrol.AccessSetLookup } +// NewWatchRefresh returns a new store with awareness of changes to the requester's access. +func NewWatchRefresh(s types.Store, asl accesscontrol.AccessSetLookup) *WatchRefresh { + return &WatchRefresh{ + Store: s, + asl: asl, + } +} + +// Watch performs a watch request which halts if the user's access level changes. func (w *WatchRefresh) Watch(apiOp *types.APIRequest, schema *types.APISchema, wr types.WatchRequest) (chan types.APIEvent, error) { user, ok := request.UserFrom(apiOp.Context()) if !ok { diff --git a/pkg/stores/sqlpartition/listprocessor/processor.go b/pkg/stores/sqlpartition/listprocessor/processor.go new file mode 100644 index 00000000..0ea15788 --- /dev/null +++ b/pkg/stores/sqlpartition/listprocessor/processor.go @@ -0,0 +1,201 @@ +// Package listprocessor contains methods for filtering, sorting, and paginating lists of objects. +package listprocessor + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/rancher/apiserver/pkg/apierror" + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/lasso/pkg/cache/sql/informer" + "github.com/rancher/lasso/pkg/cache/sql/partition" + "github.com/rancher/wrangler/v3/pkg/schemas/validation" + "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" + projectsOrNamespacesVar = "projectsornamespaces" + projectIDFieldLabel = "field.cattle.io/projectId" + + orOp = "," + notOp = "!" +) + +var opReg = regexp.MustCompile(`[!]?=`) + +// ListOptions represents the query parameters that may be included in a list request. +type ListOptions struct { + ChunkSize int + Resume string + Filters []informer.OrFilter + Sort informer.Sort + Pagination informer.Pagination +} + +type Cache interface { + // ListByOptions returns objects according to the specified list options and partitions. + // Specifically: + // - an unstructured list of resources belonging to any of the specified partitions + // - the total number of resources (returned list might be a subset depending on pagination options in lo) + // - a continue token, if there are more pages after the returned one + // - an error instead of all of the above if anything went wrong + ListByOptions(ctx context.Context, lo informer.ListOptions, partitions []partition.Partition, namespace string) (*unstructured.UnstructuredList, int, string, error) +} + +// ParseQuery parses the query params of a request and returns a ListOptions. +func ParseQuery(apiOp *types.APIRequest, namespaceCache Cache) (informer.ListOptions, error) { + opts := informer.ListOptions{} + + opts.ChunkSize = getLimit(apiOp) + + q := apiOp.Request.URL.Query() + cont := q.Get(continueParam) + opts.Resume = cont + + filterParams := q[filterParam] + filterOpts := []informer.OrFilter{} + for _, filters := range filterParams { + orFilters := strings.Split(filters, orOp) + orFilter := informer.OrFilter{} + for _, filter := range orFilters { + var op informer.Op + if strings.Contains(filter, "!=") { + op = "!=" + } + filter := opReg.Split(filter, -1) + if len(filter) != 2 { + continue + } + usePartialMatch := !(strings.HasPrefix(filter[1], `'`) && strings.HasSuffix(filter[1], `'`)) + value := strings.TrimSuffix(strings.TrimPrefix(filter[1], "'"), "'") + orFilter.Filters = append(orFilter.Filters, informer.Filter{Field: strings.Split(filter[0], "."), Match: value, Op: op, Partial: usePartialMatch}) + } + filterOpts = append(filterOpts, orFilter) + } + opts.Filters = filterOpts + + sortOpts := informer.Sort{} + sortKeys := q.Get(sortParam) + if sortKeys != "" { + sortParts := strings.SplitN(sortKeys, ",", 2) + primaryField := sortParts[0] + if primaryField != "" && primaryField[0] == '-' { + sortOpts.PrimaryOrder = informer.DESC + primaryField = primaryField[1:] + } + if primaryField != "" { + sortOpts.PrimaryField = strings.Split(primaryField, ".") + } + if len(sortParts) > 1 { + secondaryField := sortParts[1] + if secondaryField != "" && secondaryField[0] == '-' { + sortOpts.SecondaryOrder = informer.DESC + secondaryField = secondaryField[1:] + } + if secondaryField != "" { + sortOpts.SecondaryField = strings.Split(secondaryField, ".") + } + } + } + opts.Sort = sortOpts + + var err error + pagination := informer.Pagination{} + pagination.PageSize, err = strconv.Atoi(q.Get(pageSizeParam)) + if err != nil { + pagination.PageSize = 0 + } + pagination.Page, err = strconv.Atoi(q.Get(pageParam)) + if err != nil { + pagination.Page = 1 + } + opts.Pagination = pagination + + var op informer.Op + projectsOrNamespaces := q.Get(projectsOrNamespacesVar) + if projectsOrNamespaces == "" { + projectsOrNamespaces = q.Get(projectsOrNamespacesVar + notOp) + if projectsOrNamespaces != "" { + op = informer.NotEq + } + } + if projectsOrNamespaces != "" { + projOrNSFilters, err := parseNamespaceOrProjectFilters(apiOp.Context(), projectsOrNamespaces, op, namespaceCache) + if err != nil { + return opts, err + } + if projOrNSFilters == nil { + return opts, apierror.NewAPIError(validation.NotFound, fmt.Sprintf("could not find any namespacess named [%s] or namespaces belonging to project named [%s]", projectsOrNamespaces, projectsOrNamespaces)) + } + if op == informer.NotEq { + for _, filter := range projOrNSFilters { + opts.Filters = append(opts.Filters, informer.OrFilter{Filters: []informer.Filter{filter}}) + } + } else { + opts.Filters = append(opts.Filters, informer.OrFilter{Filters: projOrNSFilters}) + } + } + + return opts, nil +} + +// getLimit extracts the limit parameter from the request or sets a default of 100000. +// The default limit can be explicitly disabled by setting it to zero or negative. +// If the default is accepted, clients must be aware that the list may be incomplete, and use the "continue" token to get the next chunk of results. +func getLimit(apiOp *types.APIRequest) int { + limitString := apiOp.Request.URL.Query().Get(limitParam) + limit, err := strconv.Atoi(limitString) + if err != nil { + limit = defaultLimit + } + return limit +} + +func parseNamespaceOrProjectFilters(ctx context.Context, projOrNS string, op informer.Op, namespaceInformer Cache) ([]informer.Filter, error) { + var filters []informer.Filter + for _, pn := range strings.Split(projOrNS, ",") { + uList, _, _, err := namespaceInformer.ListByOptions(ctx, informer.ListOptions{ + Filters: []informer.OrFilter{ + { + Filters: []informer.Filter{ + { + Field: []string{"metadata", "name"}, + Match: pn, + Op: informer.Eq, + }, + { + Field: []string{"metadata", "labels[field.cattle.io/projectId]"}, + Match: pn, + Op: informer.Eq, + }, + }, + }, + }, + }, []partition.Partition{{Passthrough: true}}, "") + if err != nil { + return filters, err + } + for _, item := range uList.Items { + filters = append(filters, informer.Filter{ + Field: []string{"metadata", "namespace"}, + Match: item.GetName(), + Op: op, + Partial: false, + }) + } + continue + } + + return filters, nil +} diff --git a/pkg/stores/sqlpartition/listprocessor/processor_test.go b/pkg/stores/sqlpartition/listprocessor/processor_test.go new file mode 100644 index 00000000..5b743905 --- /dev/null +++ b/pkg/stores/sqlpartition/listprocessor/processor_test.go @@ -0,0 +1,524 @@ +package listprocessor + +import ( + "context" + "fmt" + "net/http" + "net/url" + "testing" + + "github.com/golang/mock/gomock" + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/lasso/pkg/cache/sql/informer" + "github.com/rancher/lasso/pkg/cache/sql/partition" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +//go:generate mockgen --build_flags=--mod=mod -package listprocessor -destination ./proxy_mocks_test.go github.com/rancher/steve/pkg/stores/sqlproxy Cache + +func TestParseQuery(t *testing.T) { + type testCase struct { + description string + setupNSCache func() Cache + nsc Cache + req *types.APIRequest + expectedLO informer.ListOptions + errExpected bool + } + var tests []testCase + tests = append(tests, testCase{ + description: "ParseQuery() with no errors returned should returned no errors. Should have proper defaults set.", + req: &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{RawQuery: ""}, + }, + }, + expectedLO: informer.ListOptions{ + ChunkSize: defaultLimit, + Filters: make([]informer.OrFilter, 0), + Pagination: informer.Pagination{ + Page: 1, + }, + }, + setupNSCache: func() Cache { + return nil + }, + }) + tests = append(tests, testCase{ + description: "ParseQuery() with no errors returned should returned no errors. If projectsornamespaces is not empty" + + " and nsc returns namespaces, they should be included as filters.", + req: &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{RawQuery: "projectsornamespaces=somethin"}, + }, + }, + expectedLO: informer.ListOptions{ + ChunkSize: defaultLimit, + Filters: []informer.OrFilter{ + { + Filters: []informer.Filter{ + { + Field: []string{"metadata", "namespace"}, + Match: "ns1", + Op: "", + Partial: false, + }, + }, + }, + }, + Pagination: informer.Pagination{ + Page: 1, + }, + }, + setupNSCache: func() Cache { + list := &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "ns1", + }, + }, + }, + }, + } + nsc := NewMockCache(gomock.NewController(t)) + nsc.EXPECT().ListByOptions(context.Background(), informer.ListOptions{ + Filters: []informer.OrFilter{ + { + Filters: []informer.Filter{ + { + Field: []string{"metadata", "name"}, + Match: "somethin", + Op: informer.Eq, + }, + { + Field: []string{"metadata", "labels[field.cattle.io/projectId]"}, + Match: "somethin", + Op: informer.Eq, + }, + }, + }, + }, + }, []partition.Partition{{Passthrough: true}}, "").Return(list, len(list.Items), "", nil) + return nsc + }, + }) + tests = append(tests, testCase{ + description: "ParseQuery() with a namespace informer error returned should return an error.", + req: &types.APIRequest{ + Request: &http.Request{ + // namespace informer is only used if projectsornamespace param is given + URL: &url.URL{RawQuery: "projectsornamespaces=somethin"}, + }, + }, + expectedLO: informer.ListOptions{ + ChunkSize: defaultLimit, + Filters: []informer.OrFilter{ + { + Filters: []informer.Filter{ + { + Field: []string{"metadata", "namespace"}, + Match: "ns1", + Op: "", + Partial: false, + }, + }, + }, + }, + Pagination: informer.Pagination{ + Page: 1, + }, + }, + errExpected: true, + setupNSCache: func() Cache { + nsi := NewMockCache(gomock.NewController(t)) + nsi.EXPECT().ListByOptions(context.Background(), informer.ListOptions{ + Filters: []informer.OrFilter{ + { + Filters: []informer.Filter{ + { + Field: []string{"metadata", "name"}, + Match: "somethin", + Op: informer.Eq, + }, + { + Field: []string{"metadata", "labels[field.cattle.io/projectId]"}, + Match: "somethin", + Op: informer.Eq, + }, + }, + }, + }, + }, []partition.Partition{{Passthrough: true}}, "").Return(nil, 0, "", fmt.Errorf("error")) + return nsi + }, + }) + tests = append(tests, testCase{ + description: "ParseQuery() with no errors returned should returned no errors. If projectsornamespaces is not empty" + + " and nsc does not return namespaces, an error should be returned.", + req: &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{RawQuery: "projectsornamespaces=somethin"}, + }, + }, + expectedLO: informer.ListOptions{ + ChunkSize: defaultLimit, + Filters: []informer.OrFilter{ + { + Filters: []informer.Filter{ + { + Field: []string{"metadata", "namespace"}, + Match: "ns1", + Op: "", + Partial: false, + }, + }, + }, + }, + Pagination: informer.Pagination{ + Page: 1, + }, + }, + errExpected: true, + setupNSCache: func() Cache { + list := &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{}, + } + nsi := NewMockCache(gomock.NewController(t)) + nsi.EXPECT().ListByOptions(context.Background(), informer.ListOptions{ + Filters: []informer.OrFilter{ + { + Filters: []informer.Filter{ + { + Field: []string{"metadata", "name"}, + Match: "somethin", + Op: informer.Eq, + }, + { + Field: []string{"metadata", "labels[field.cattle.io/projectId]"}, + Match: "somethin", + Op: informer.Eq, + }, + }, + }, + }, + }, []partition.Partition{{Passthrough: true}}, "").Return(list, len(list.Items), "", nil) + return nsi + }, + }) + tests = append(tests, testCase{ + description: "ParseQuery() with filter param set should include filter with partial set to true in list options.", + req: &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{RawQuery: "filter=a=c"}, + }, + }, + expectedLO: informer.ListOptions{ + ChunkSize: defaultLimit, + Filters: []informer.OrFilter{ + { + Filters: []informer.Filter{ + { + Field: []string{"a"}, + Match: "c", + Op: "", + Partial: true, + }, + }, + }, + }, + Pagination: informer.Pagination{ + Page: 1, + }, + }, + setupNSCache: func() Cache { + return nil + }, + }) + tests = append(tests, testCase{ + description: "ParseQuery() with filter param set, with value in single quotes, should include filter with partial set to false in list options.", + req: &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{RawQuery: "filter=a='c'"}, + }, + }, + expectedLO: informer.ListOptions{ + ChunkSize: defaultLimit, + Filters: []informer.OrFilter{ + { + Filters: []informer.Filter{ + { + Field: []string{"a"}, + Match: "c", + Op: "", + Partial: false, + }, + }, + }, + }, + Pagination: informer.Pagination{ + Page: 1, + }, + }, + setupNSCache: func() Cache { + return nil + }, + }) + tests = append(tests, testCase{ + description: "ParseQuery() with multiple filter params, should include multiple or filters.", + req: &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{RawQuery: "filter=a=c&filter=b=d"}, + }, + }, + expectedLO: informer.ListOptions{ + ChunkSize: defaultLimit, + Filters: []informer.OrFilter{ + { + Filters: []informer.Filter{ + { + Field: []string{"a"}, + Match: "c", + Op: "", + Partial: true, + }, + }, + }, + { + Filters: []informer.Filter{ + { + Field: []string{"b"}, + Match: "d", + Op: "", + Partial: true, + }, + }, + }, + }, + Pagination: informer.Pagination{ + Page: 1, + }, + }, + setupNSCache: func() Cache { + return nil + }, + }) + tests = append(tests, testCase{ + description: "ParseQuery() with a filter param with a comma separate value, should include a single or filter with" + + " multiple filters.", + req: &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{RawQuery: "filter=a=c,b=d"}, + }, + }, + expectedLO: informer.ListOptions{ + ChunkSize: defaultLimit, + Filters: []informer.OrFilter{ + { + Filters: []informer.Filter{ + { + Field: []string{"a"}, + Match: "c", + Op: "", + Partial: true, + }, + { + Field: []string{"b"}, + Match: "d", + Op: "", + Partial: true, + }, + }, + }, + }, + Pagination: informer.Pagination{ + Page: 1, + }, + }, + setupNSCache: func() Cache { + return nil + }, + }) + tests = append(tests, testCase{ + description: "ParseQuery() with no errors returned should returned no errors. If one sort param is given, primary field" + + " sort option should be set", + req: &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{RawQuery: "sort=metadata.name"}, + }, + }, + expectedLO: informer.ListOptions{ + ChunkSize: defaultLimit, + Sort: informer.Sort{ + PrimaryField: []string{"metadata", "name"}, + }, + Filters: make([]informer.OrFilter, 0), + Pagination: informer.Pagination{ + Page: 1, + }, + }, + setupNSCache: func() Cache { + return nil + }, + }) + tests = append(tests, testCase{ + description: "ParseQuery() with no errors returned should returned no errors. If one sort param is given primary field " + + "and hyphen prefix for field value, sort option should be set with descending order.", + req: &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{RawQuery: "sort=-metadata.name"}, + }, + }, + expectedLO: informer.ListOptions{ + ChunkSize: defaultLimit, + Sort: informer.Sort{ + PrimaryField: []string{"metadata", "name"}, + PrimaryOrder: informer.DESC, + }, + Filters: make([]informer.OrFilter, 0), + Pagination: informer.Pagination{ + Page: 1, + }, + }, + setupNSCache: func() Cache { + return nil + }, + }) + tests = append(tests, testCase{ + description: "ParseQuery() with no errors returned should returned no errors. If two sort params are given, sort " + + "options with primary field and secondary field should be set.", + req: &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{RawQuery: "sort=-metadata.name,spec.something"}, + }, + }, + expectedLO: informer.ListOptions{ + ChunkSize: defaultLimit, + Sort: informer.Sort{ + PrimaryField: []string{"metadata", "name"}, + PrimaryOrder: informer.DESC, + SecondaryField: []string{"spec", "something"}, + SecondaryOrder: informer.ASC, + }, + Filters: make([]informer.OrFilter, 0), + Pagination: informer.Pagination{ + Page: 1, + }, + }, + setupNSCache: func() Cache { + return nil + }, + }) + tests = append(tests, testCase{ + description: "ParseQuery() with no errors returned should returned no errors. If continue params is given, resume" + + " should be set with assigned value.", + req: &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{RawQuery: "continue=5"}, + }, + }, + expectedLO: informer.ListOptions{ + ChunkSize: defaultLimit, + Resume: "5", + Filters: make([]informer.OrFilter, 0), + Pagination: informer.Pagination{ + Page: 1, + }, + }, + setupNSCache: func() Cache { + return nil + }, + }) + tests = append(tests, testCase{ + description: "ParseQuery() with no errors returned should returned no errors. If continue param is given, resume" + + " should be set with assigned value.", + req: &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{RawQuery: "continue=5"}, + }, + }, + expectedLO: informer.ListOptions{ + ChunkSize: defaultLimit, + Resume: "5", + Filters: make([]informer.OrFilter, 0), + Pagination: informer.Pagination{ + Page: 1, + }, + }, + setupNSCache: func() Cache { + return nil + }, + }) + tests = append(tests, testCase{ + description: "ParseQuery() with no errors returned should returned no errors. If limit param is given, chunksize" + + " should be set with assigned value.", + req: &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{RawQuery: "limit=3"}, + }, + }, + expectedLO: informer.ListOptions{ + ChunkSize: 3, + Filters: make([]informer.OrFilter, 0), + Pagination: informer.Pagination{ + Page: 1, + }, + }, + setupNSCache: func() Cache { + return nil + }, + }) + tests = append(tests, testCase{ + description: "ParseQuery() with no errors returned should returned no errors. If page param is given, page" + + " should be set with assigned value.", + req: &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{RawQuery: "page=3"}, + }, + }, + expectedLO: informer.ListOptions{ + ChunkSize: defaultLimit, + Filters: make([]informer.OrFilter, 0), + Pagination: informer.Pagination{ + Page: 3, + }, + }, + setupNSCache: func() Cache { + return nil + }, + }) + tests = append(tests, testCase{ + description: "ParseQuery() with no errors returned should returned no errors. If pagesize param is given, pageSize" + + " should be set with assigned value.", + req: &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{RawQuery: "pagesize=20"}, + }, + }, + expectedLO: informer.ListOptions{ + ChunkSize: defaultLimit, + Filters: make([]informer.OrFilter, 0), + Pagination: informer.Pagination{ + PageSize: 20, + Page: 1, + }, + }, + setupNSCache: func() Cache { + return nil + }, + }) + t.Parallel() + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + test.nsc = test.setupNSCache() + lo, err := ParseQuery(test.req, test.nsc) + if test.errExpected { + assert.NotNil(t, err) + return + } + assert.Equal(t, test.expectedLO, lo) + }) + } +} diff --git a/pkg/stores/sqlpartition/listprocessor/proxy_mocks_test.go b/pkg/stores/sqlpartition/listprocessor/proxy_mocks_test.go new file mode 100644 index 00000000..e1618aa5 --- /dev/null +++ b/pkg/stores/sqlpartition/listprocessor/proxy_mocks_test.go @@ -0,0 +1,55 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/rancher/steve/pkg/stores/sqlproxy (interfaces: Cache) + +// Package listprocessor is a generated GoMock package. +package listprocessor + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + informer "github.com/rancher/lasso/pkg/cache/sql/informer" + partition "github.com/rancher/lasso/pkg/cache/sql/partition" + unstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// MockCache is a mock of Cache interface. +type MockCache struct { + ctrl *gomock.Controller + recorder *MockCacheMockRecorder +} + +// MockCacheMockRecorder is the mock recorder for MockCache. +type MockCacheMockRecorder struct { + mock *MockCache +} + +// NewMockCache creates a new mock instance. +func NewMockCache(ctrl *gomock.Controller) *MockCache { + mock := &MockCache{ctrl: ctrl} + mock.recorder = &MockCacheMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCache) EXPECT() *MockCacheMockRecorder { + return m.recorder +} + +// ListByOptions mocks base method. +func (m *MockCache) ListByOptions(arg0 context.Context, arg1 informer.ListOptions, arg2 []partition.Partition, arg3 string) (*unstructured.UnstructuredList, int, string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListByOptions", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*unstructured.UnstructuredList) + ret1, _ := ret[1].(int) + ret2, _ := ret[2].(string) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 +} + +// ListByOptions indicates an expected call of ListByOptions. +func (mr *MockCacheMockRecorder) ListByOptions(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByOptions", reflect.TypeOf((*MockCache)(nil).ListByOptions), arg0, arg1, arg2, arg3) +} diff --git a/pkg/stores/sqlpartition/partition_mocks_test.go b/pkg/stores/sqlpartition/partition_mocks_test.go new file mode 100644 index 00000000..bf39c16d --- /dev/null +++ b/pkg/stores/sqlpartition/partition_mocks_test.go @@ -0,0 +1,186 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/rancher/steve/pkg/stores/sqlpartition (interfaces: Partitioner,UnstructuredStore) + +// Package sqlpartition is a generated GoMock package. +package sqlpartition + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + types "github.com/rancher/apiserver/pkg/types" + partition "github.com/rancher/lasso/pkg/cache/sql/partition" + unstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + watch "k8s.io/apimachinery/pkg/watch" +) + +// MockPartitioner is a mock of Partitioner interface. +type MockPartitioner struct { + ctrl *gomock.Controller + recorder *MockPartitionerMockRecorder +} + +// MockPartitionerMockRecorder is the mock recorder for MockPartitioner. +type MockPartitionerMockRecorder struct { + mock *MockPartitioner +} + +// NewMockPartitioner creates a new mock instance. +func NewMockPartitioner(ctrl *gomock.Controller) *MockPartitioner { + mock := &MockPartitioner{ctrl: ctrl} + mock.recorder = &MockPartitionerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPartitioner) EXPECT() *MockPartitionerMockRecorder { + return m.recorder +} + +// All mocks base method. +func (m *MockPartitioner) All(arg0 *types.APIRequest, arg1 *types.APISchema, arg2, arg3 string) ([]partition.Partition, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "All", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].([]partition.Partition) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// All indicates an expected call of All. +func (mr *MockPartitionerMockRecorder) All(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "All", reflect.TypeOf((*MockPartitioner)(nil).All), arg0, arg1, arg2, arg3) +} + +// Store mocks base method. +func (m *MockPartitioner) Store() UnstructuredStore { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Store") + ret0, _ := ret[0].(UnstructuredStore) + return ret0 +} + +// Store indicates an expected call of Store. +func (mr *MockPartitionerMockRecorder) Store() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Store", reflect.TypeOf((*MockPartitioner)(nil).Store)) +} + +// MockUnstructuredStore is a mock of UnstructuredStore interface. +type MockUnstructuredStore struct { + ctrl *gomock.Controller + recorder *MockUnstructuredStoreMockRecorder +} + +// MockUnstructuredStoreMockRecorder is the mock recorder for MockUnstructuredStore. +type MockUnstructuredStoreMockRecorder struct { + mock *MockUnstructuredStore +} + +// NewMockUnstructuredStore creates a new mock instance. +func NewMockUnstructuredStore(ctrl *gomock.Controller) *MockUnstructuredStore { + mock := &MockUnstructuredStore{ctrl: ctrl} + mock.recorder = &MockUnstructuredStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockUnstructuredStore) EXPECT() *MockUnstructuredStoreMockRecorder { + return m.recorder +} + +// ByID mocks base method. +func (m *MockUnstructuredStore) ByID(arg0 *types.APIRequest, arg1 *types.APISchema, arg2 string) (*unstructured.Unstructured, []types.Warning, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ByID", arg0, arg1, arg2) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].([]types.Warning) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ByID indicates an expected call of ByID. +func (mr *MockUnstructuredStoreMockRecorder) ByID(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ByID", reflect.TypeOf((*MockUnstructuredStore)(nil).ByID), arg0, arg1, arg2) +} + +// Create mocks base method. +func (m *MockUnstructuredStore) Create(arg0 *types.APIRequest, arg1 *types.APISchema, arg2 types.APIObject) (*unstructured.Unstructured, []types.Warning, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", arg0, arg1, arg2) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].([]types.Warning) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Create indicates an expected call of Create. +func (mr *MockUnstructuredStoreMockRecorder) Create(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockUnstructuredStore)(nil).Create), arg0, arg1, arg2) +} + +// Delete mocks base method. +func (m *MockUnstructuredStore) Delete(arg0 *types.APIRequest, arg1 *types.APISchema, arg2 string) (*unstructured.Unstructured, []types.Warning, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", arg0, arg1, arg2) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].([]types.Warning) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Delete indicates an expected call of Delete. +func (mr *MockUnstructuredStoreMockRecorder) Delete(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockUnstructuredStore)(nil).Delete), arg0, arg1, arg2) +} + +// ListByPartitions mocks base method. +func (m *MockUnstructuredStore) ListByPartitions(arg0 *types.APIRequest, arg1 *types.APISchema, arg2 []partition.Partition) ([]unstructured.Unstructured, int, string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListByPartitions", arg0, arg1, arg2) + ret0, _ := ret[0].([]unstructured.Unstructured) + ret1, _ := ret[1].(int) + ret2, _ := ret[2].(string) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 +} + +// ListByPartitions indicates an expected call of ListByPartitions. +func (mr *MockUnstructuredStoreMockRecorder) ListByPartitions(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByPartitions", reflect.TypeOf((*MockUnstructuredStore)(nil).ListByPartitions), arg0, arg1, arg2) +} + +// Update mocks base method. +func (m *MockUnstructuredStore) Update(arg0 *types.APIRequest, arg1 *types.APISchema, arg2 types.APIObject, arg3 string) (*unstructured.Unstructured, []types.Warning, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].([]types.Warning) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Update indicates an expected call of Update. +func (mr *MockUnstructuredStoreMockRecorder) Update(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockUnstructuredStore)(nil).Update), arg0, arg1, arg2, arg3) +} + +// WatchByPartitions mocks base method. +func (m *MockUnstructuredStore) WatchByPartitions(arg0 *types.APIRequest, arg1 *types.APISchema, arg2 types.WatchRequest, arg3 []partition.Partition) (chan watch.Event, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WatchByPartitions", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(chan watch.Event) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// WatchByPartitions indicates an expected call of WatchByPartitions. +func (mr *MockUnstructuredStoreMockRecorder) WatchByPartitions(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WatchByPartitions", reflect.TypeOf((*MockUnstructuredStore)(nil).WatchByPartitions), arg0, arg1, arg2, arg3) +} diff --git a/pkg/stores/sqlpartition/partitioner.go b/pkg/stores/sqlpartition/partitioner.go new file mode 100644 index 00000000..006df05c --- /dev/null +++ b/pkg/stores/sqlpartition/partitioner.go @@ -0,0 +1,118 @@ +package sqlpartition + +import ( + "fmt" + "sort" + + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/lasso/pkg/cache/sql/partition" + "github.com/rancher/steve/pkg/accesscontrol" + "github.com/rancher/steve/pkg/attributes" + "github.com/rancher/wrangler/v3/pkg/kv" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/watch" +) + +var ( + passthroughPartitions = []partition.Partition{ + {Passthrough: true}, + } +) + +// UnstructuredStore is like types.Store but deals in k8s unstructured objects instead of apiserver types. +// This interface exists in order for store to be mocked in tests +type UnstructuredStore interface { + ByID(apiOp *types.APIRequest, schema *types.APISchema, id string) (*unstructured.Unstructured, []types.Warning, error) + Create(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject) (*unstructured.Unstructured, []types.Warning, error) + Update(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject, id string) (*unstructured.Unstructured, []types.Warning, error) + Delete(apiOp *types.APIRequest, schema *types.APISchema, id string) (*unstructured.Unstructured, []types.Warning, error) + + ListByPartitions(apiOp *types.APIRequest, schema *types.APISchema, partitions []partition.Partition) ([]unstructured.Unstructured, int, string, error) + WatchByPartitions(apiOp *types.APIRequest, schema *types.APISchema, wr types.WatchRequest, partitions []partition.Partition) (chan watch.Event, error) +} + +// rbacPartitioner is an implementation of the sqlpartition.Partitioner interface. +type rbacPartitioner struct { + proxyStore UnstructuredStore +} + +// All returns a slice of partitions applicable to the API schema and the user's access level. +// For watching individual resources or for blanket access permissions, it returns the passthrough partition. +// For more granular permissions, it returns a slice of partitions matching an allowed namespace or resource names. +func (p *rbacPartitioner) All(apiOp *types.APIRequest, schema *types.APISchema, verb, id string) ([]partition.Partition, error) { + switch verb { + case "list": + fallthrough + case "watch": + if id != "" { + ns, name := kv.RSplit(id, "/") + return []partition.Partition{ + { + Namespace: ns, + All: false, + Passthrough: false, + Names: sets.New[string](name), + }, + }, nil + } + partitions, passthrough := isPassthrough(apiOp, schema, verb) + if passthrough { + return passthroughPartitions, nil + } + sort.Slice(partitions, func(i, j int) bool { + return partitions[i].Namespace < partitions[j].Namespace + }) + return partitions, nil + default: + return nil, fmt.Errorf("parition all: invalid verb %s", verb) + } +} + +// Store returns an Store suited to listing and watching resources by partition. +func (p *rbacPartitioner) Store() UnstructuredStore { + return p.proxyStore +} + +// isPassthrough determines whether a request can be passed through directly to the underlying store +// or if the results need to be partitioned by namespace and name based on the requester's access. +func isPassthrough(apiOp *types.APIRequest, schema *types.APISchema, verb string) ([]partition.Partition, bool) { + accessListByVerb, _ := attributes.Access(schema).(accesscontrol.AccessListByVerb) + if accessListByVerb.All(verb) { + return nil, true + } + + resources := accessListByVerb.Granted(verb) + if apiOp.Namespace != "" { + if resources[apiOp.Namespace].All { + return nil, true + } + return []partition.Partition{ + { + Namespace: apiOp.Namespace, + Names: sets.Set[string](resources[apiOp.Namespace].Names), + }, + }, false + } + + var result []partition.Partition + + if attributes.Namespaced(schema) { + for k, v := range resources { + result = append(result, partition.Partition{ + Namespace: k, + All: v.All, + Names: sets.Set[string](v.Names), + }) + } + } else { + for _, v := range resources { + result = append(result, partition.Partition{ + All: v.All, + Names: sets.Set[string](v.Names), + }) + } + } + + return result, false +} diff --git a/pkg/stores/sqlpartition/partitioner_test.go b/pkg/stores/sqlpartition/partitioner_test.go new file mode 100644 index 00000000..28fad185 --- /dev/null +++ b/pkg/stores/sqlpartition/partitioner_test.go @@ -0,0 +1,263 @@ +package sqlpartition + +import ( + "testing" + + "github.com/golang/mock/gomock" + + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/lasso/pkg/cache/sql/partition" + "github.com/rancher/steve/pkg/accesscontrol" + "github.com/rancher/wrangler/v3/pkg/schemas" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/util/sets" +) + +func TestAll(t *testing.T) { + tests := []struct { + name string + apiOp *types.APIRequest + id string + schema *types.APISchema + wantPartitions []partition.Partition + }{ + { + name: "all passthrough", + apiOp: &types.APIRequest{}, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "foo", + Attributes: map[string]interface{}{ + "access": accesscontrol.AccessListByVerb{ + "list": accesscontrol.AccessList{ + accesscontrol.Access{ + Namespace: "*", + ResourceName: "*", + }, + }, + }, + }, + }, + }, + wantPartitions: passthroughPartitions, + }, + { + name: "global access for global request", + apiOp: &types.APIRequest{}, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "foo", + Attributes: map[string]interface{}{ + "access": accesscontrol.AccessListByVerb{ + "list": accesscontrol.AccessList{ + accesscontrol.Access{ + Namespace: "*", + ResourceName: "r1", + }, + accesscontrol.Access{ + Namespace: "*", + ResourceName: "r2", + }, + }, + }, + }, + }, + }, + wantPartitions: []partition.Partition{ + { + Names: sets.New[string]("r1", "r2"), + }, + }, + }, + { + name: "namespace access for global request", + apiOp: &types.APIRequest{}, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "foo", + Attributes: map[string]interface{}{ + "namespaced": true, + "access": accesscontrol.AccessListByVerb{ + "list": accesscontrol.AccessList{ + accesscontrol.Access{ + Namespace: "n1", + ResourceName: "*", + }, + accesscontrol.Access{ + Namespace: "n2", + ResourceName: "*", + }, + }, + }, + }, + }, + }, + wantPartitions: []partition.Partition{ + { + Namespace: "n1", + All: true, + }, + { + Namespace: "n2", + All: true, + }, + }, + }, + { + name: "namespace access for namespaced request", + apiOp: &types.APIRequest{ + Namespace: "n1", + }, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "foo", + Attributes: map[string]interface{}{ + "namespaced": true, + "access": accesscontrol.AccessListByVerb{ + "list": accesscontrol.AccessList{ + accesscontrol.Access{ + Namespace: "n1", + ResourceName: "*", + }, + }, + }, + }, + }, + }, + wantPartitions: passthroughPartitions, + }, + { + // we still get a partition even if there is no access to it, it will be rejected by the API server later + name: "namespace access for invalid namespaced request", + apiOp: &types.APIRequest{ + Namespace: "n2", + }, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "foo", + Attributes: map[string]interface{}{ + "namespaced": true, + "access": accesscontrol.AccessListByVerb{ + "list": accesscontrol.AccessList{ + accesscontrol.Access{ + Namespace: "n1", + ResourceName: "*", + }, + }, + }, + }, + }, + }, + wantPartitions: []partition.Partition{ + { + Namespace: "n2", + }, + }, + }, + { + name: "by names access for global request", + apiOp: &types.APIRequest{}, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "foo", + Attributes: map[string]interface{}{ + "namespaced": true, + "access": accesscontrol.AccessListByVerb{ + "list": accesscontrol.AccessList{ + accesscontrol.Access{ + Namespace: "n1", + ResourceName: "r1", + }, + accesscontrol.Access{ + Namespace: "n1", + ResourceName: "r2", + }, + accesscontrol.Access{ + Namespace: "n2", + ResourceName: "r1", + }, + }, + }, + }, + }, + }, + wantPartitions: []partition.Partition{ + { + Namespace: "n1", + Names: sets.New[string]("r1", "r2"), + }, + { + Namespace: "n2", + Names: sets.New[string]("r1"), + }, + }, + }, + { + name: "by names access for namespaced request", + apiOp: &types.APIRequest{ + Namespace: "n1", + }, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "foo", + Attributes: map[string]interface{}{ + "namespaced": true, + "access": accesscontrol.AccessListByVerb{ + "list": accesscontrol.AccessList{ + accesscontrol.Access{ + Namespace: "n1", + ResourceName: "r1", + }, + accesscontrol.Access{ + Namespace: "n2", + ResourceName: "r1", + }, + }, + }, + }, + }, + }, + wantPartitions: []partition.Partition{ + { + Namespace: "n1", + Names: sets.New[string]("r1"), + }, + }, + }, + { + name: "by id", + apiOp: &types.APIRequest{}, + id: "n1/r1", + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "foo", + }, + }, + wantPartitions: []partition.Partition{ + { + Namespace: "n1", + Names: sets.New[string]("r1"), + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + partitioner := rbacPartitioner{} + verb := "list" + gotPartitions, gotErr := partitioner.All(test.apiOp, test.schema, verb, test.id) + assert.Nil(t, gotErr) + assert.Equal(t, test.wantPartitions, gotPartitions) + }) + } +} + +func TestStore(t *testing.T) { + expectedStore := NewMockUnstructuredStore(gomock.NewController(t)) + rp := rbacPartitioner{ + proxyStore: expectedStore, + } + store := rp.Store() + assert.Equal(t, expectedStore, store) +} diff --git a/pkg/stores/sqlpartition/store.go b/pkg/stores/sqlpartition/store.go new file mode 100644 index 00000000..460a5f0d --- /dev/null +++ b/pkg/stores/sqlpartition/store.go @@ -0,0 +1,142 @@ +// Package sqlpartition implements a store which converts a request to partitions based on the user's rbac for +// the resource. For example, a user may request all items of resource A, but only have permissions for resource A in +// namespaces x,y,z. The partitions will then store that information and be passed to the next store. +package sqlpartition + +import ( + "context" + + "github.com/rancher/apiserver/pkg/types" + lassopartition "github.com/rancher/lasso/pkg/cache/sql/partition" + "github.com/rancher/steve/pkg/accesscontrol" + "github.com/rancher/steve/pkg/stores/partition" +) + +// Partitioner is an interface for interacting with partitions. +type Partitioner interface { + All(apiOp *types.APIRequest, schema *types.APISchema, verb, id string) ([]lassopartition.Partition, error) + Store() UnstructuredStore +} + +type SchemaColumnSetter interface { + SetColumns(ctx context.Context, schema *types.APISchema) error +} + +// Store implements types.proxyStore for partitions. +type Store struct { + Partitioner Partitioner + asl accesscontrol.AccessSetLookup +} + +// NewStore creates a types.proxyStore implementation with a partitioner +func NewStore(store UnstructuredStore, asl accesscontrol.AccessSetLookup) *Store { + s := &Store{ + Partitioner: &rbacPartitioner{ + proxyStore: store, + }, + asl: asl, + } + + return s +} + +// Delete deletes an object from a store. +func (s *Store) Delete(apiOp *types.APIRequest, schema *types.APISchema, id string) (types.APIObject, error) { + target := s.Partitioner.Store() + + obj, warnings, err := target.Delete(apiOp, schema, id) + if err != nil { + return types.APIObject{}, err + } + return partition.ToAPI(schema, obj, warnings), nil +} + +// ByID looks up a single object by its ID. +func (s *Store) ByID(apiOp *types.APIRequest, schema *types.APISchema, id string) (types.APIObject, error) { + target := s.Partitioner.Store() + + obj, warnings, err := target.ByID(apiOp, schema, id) + if err != nil { + return types.APIObject{}, err + } + return partition.ToAPI(schema, obj, warnings), nil +} + +// List returns a list of objects across all applicable partitions. +// If pagination parameters are used, it returns a segment of the list. +func (s *Store) List(apiOp *types.APIRequest, schema *types.APISchema) (types.APIObjectList, error) { + var ( + result types.APIObjectList + ) + + partitions, err := s.Partitioner.All(apiOp, schema, "list", "") + if err != nil { + return result, err + } + + store := s.Partitioner.Store() + + list, total, continueToken, err := store.ListByPartitions(apiOp, schema, partitions) + if err != nil { + return result, err + } + + result.Count = total + + for _, item := range list { + item := item.DeepCopy() + result.Objects = append(result.Objects, partition.ToAPI(schema, item, nil)) + } + + result.Revision = "" + result.Continue = continueToken + return result, nil +} + +// Create creates a single object in the store. +func (s *Store) Create(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject) (types.APIObject, error) { + target := s.Partitioner.Store() + + obj, warnings, err := target.Create(apiOp, schema, data) + if err != nil { + return types.APIObject{}, err + } + return partition.ToAPI(schema, obj, warnings), nil +} + +// Update updates a single object in the store. +func (s *Store) Update(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject, id string) (types.APIObject, error) { + target := s.Partitioner.Store() + + obj, warnings, err := target.Update(apiOp, schema, data, id) + if err != nil { + return types.APIObject{}, err + } + return partition.ToAPI(schema, obj, warnings), nil +} + +// Watch returns a channel of events for a list or resource. +func (s *Store) Watch(apiOp *types.APIRequest, schema *types.APISchema, wr types.WatchRequest) (chan types.APIEvent, error) { + partitions, err := s.Partitioner.All(apiOp, schema, "watch", wr.ID) + if err != nil { + return nil, err + } + + store := s.Partitioner.Store() + + response := make(chan types.APIEvent) + c, err := store.WatchByPartitions(apiOp, schema, wr, partitions) + if err != nil { + return nil, err + } + + go func() { + defer close(response) + + for i := range c { + response <- partition.ToAPIEvent(nil, schema, i) + } + }() + + return response, nil +} diff --git a/pkg/stores/sqlpartition/store_test.go b/pkg/stores/sqlpartition/store_test.go new file mode 100644 index 00000000..13f900f2 --- /dev/null +++ b/pkg/stores/sqlpartition/store_test.go @@ -0,0 +1,383 @@ +package sqlpartition + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "fmt" + "net/http" + "net/url" + "strconv" + "testing" + + "github.com/golang/mock/gomock" + "github.com/rancher/wrangler/v3/pkg/schemas" + "github.com/stretchr/testify/assert" + + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/lasso/pkg/cache/sql/partition" + "github.com/rancher/steve/pkg/accesscontrol" + "github.com/rancher/steve/pkg/stores/sqlproxy" + "github.com/rancher/wrangler/v3/pkg/generic" + 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" +) + +//go:generate mockgen --build_flags=--mod=mod -package sqlpartition -destination partition_mocks_test.go "github.com/rancher/steve/pkg/stores/sqlpartition" Partitioner,UnstructuredStore + +func TestList(t *testing.T) { + type testCase struct { + description string + test func(t *testing.T) + } + var tests []testCase + tests = append(tests, testCase{ + description: "List() with no errors returned should returned no errors. Should have empty reivsion, count " + + "should match number of items in list, and id should include namespace (if applicable) and name, separated" + + " by a '/'.", + test: func(t *testing.T) { + p := NewMockPartitioner(gomock.NewController(t)) + us := NewMockUnstructuredStore(gomock.NewController(t)) + s := Store{ + Partitioner: p, + } + req := &types.APIRequest{} + schema := &types.APISchema{ + Schema: &schemas.Schema{}, + } + partitions := make([]partition.Partition, 0) + uListToReturn := []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "fruitsnamespace", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + } + expectedAPIObjList := types.APIObjectList{ + Count: 1, + Revision: "", + Objects: []types.APIObject{ + { + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "fruitsnamespace", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + ID: "fruitsnamespace/fuji", + }, + }, + } + p.EXPECT().All(req, schema, "list", "").Return(partitions, nil) + p.EXPECT().Store().Return(us) + us.EXPECT().ListByPartitions(req, schema, partitions).Return(uListToReturn, len(uListToReturn), "", nil) + l, err := s.List(req, schema) + assert.Nil(t, err) + assert.Equal(t, expectedAPIObjList, l) + }, + }) + tests = append(tests, testCase{ + description: "List() with partitioner All() error returned should returned an error.", + test: func(t *testing.T) { + p := NewMockPartitioner(gomock.NewController(t)) + s := Store{ + Partitioner: p, + } + req := &types.APIRequest{} + schema := &types.APISchema{ + Schema: &schemas.Schema{}, + } + p.EXPECT().All(req, schema, "list", "").Return(nil, fmt.Errorf("error")) + _, err := s.List(req, schema) + assert.NotNil(t, err) + }, + }) + tests = append(tests, testCase{ + description: "List() with unstructured store ListByPartitions() error returned should returned an error.", + test: func(t *testing.T) { + p := NewMockPartitioner(gomock.NewController(t)) + us := NewMockUnstructuredStore(gomock.NewController(t)) + s := Store{ + Partitioner: p, + } + req := &types.APIRequest{} + schema := &types.APISchema{ + Schema: &schemas.Schema{}, + } + partitions := make([]partition.Partition, 0) + p.EXPECT().All(req, schema, "list", "").Return(partitions, nil) + p.EXPECT().Store().Return(us) + us.EXPECT().ListByPartitions(req, schema, partitions).Return(nil, 0, "", fmt.Errorf("error")) + _, err := s.List(req, schema) + assert.NotNil(t, err) + }, + }) + t.Parallel() + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { test.test(t) }) + } +} + +type mockPartitioner struct { + store sqlproxy.Store + partitions map[string][]partition.Partition +} + +func (m mockPartitioner) Lookup(apiOp *types.APIRequest, schema *types.APISchema, verb, id string) (partition.Partition, error) { + panic("not implemented") +} + +func (m mockPartitioner) All(apiOp *types.APIRequest, schema *types.APISchema, verb, id string) ([]partition.Partition, error) { + user, _ := request.UserFrom(apiOp.Request.Context()) + return m.partitions[user.GetName()], nil +} + +func (m mockPartitioner) Store() sqlproxy.Store { + return m.store +} + +type mockStore struct { + contents map[string]*unstructured.UnstructuredList + partition partition.Partition + called map[string]int +} + +func (m *mockStore) WatchByPartitions(apiOp *types.APIRequest, schema *types.APISchema, wr types.WatchRequest, partitions []partition.Partition) (chan watch.Event, error) { + //TODO implement me + panic("implement me") +} + +func (m *mockStore) ListByPartitions(apiOp *types.APIRequest, schema *types.APISchema, partitions []partition.Partition) ([]unstructured.Unstructured, string, string, error) { + list := []unstructured.Unstructured{} + revision := "" + for _, partition := range partitions { + apiOp = apiOp.Clone() + apiOp.Namespace = partition.Namespace + partial, _, err := m.List(apiOp, schema) + if err != nil { + return nil, "", "", err + } + + list = append(list, partial.Items...) + revision = partial.GetResourceVersion() + } + return list, revision, "", nil +} + +func (m *mockStore) List(apiOp *types.APIRequest, schema *types.APISchema) (*unstructured.UnstructuredList, []types.Warning, error) { + n := apiOp.Namespace + previous, ok := m.called[n] + if !ok { + m.called[n] = 1 + } else { + m.called[n] = previous + 1 + } + query, _ := url.ParseQuery(apiOp.Request.URL.RawQuery) + l := query.Get("limit") + if l == "" { + return m.contents[n], nil, nil + } + i := 0 + if c := query.Get("continue"); c != "" { + start, _ := base64.StdEncoding.DecodeString(c) + for j, obj := range m.contents[n].Items { + if string(start) == obj.GetName() { + i = j + break + } + } + } + lInt, _ := strconv.Atoi(l) + contents := m.contents[n].DeepCopy() + if len(contents.Items) > i+lInt { + contents.SetContinue(base64.StdEncoding.EncodeToString([]byte(contents.Items[i+lInt].GetName()))) + } + if i > len(contents.Items) { + return contents, nil, nil + } + if i+lInt > len(contents.Items) { + contents.Items = contents.Items[i:] + return contents, nil, nil + } + contents.Items = contents.Items[i : i+lInt] + return contents, nil, nil +} + +func (m *mockStore) ByID(apiOp *types.APIRequest, schema *types.APISchema, id string) (*unstructured.Unstructured, []types.Warning, error) { + panic("not implemented") +} + +func (m *mockStore) Create(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject) (*unstructured.Unstructured, []types.Warning, error) { + panic("not implemented") +} + +func (m *mockStore) Update(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject, id string) (*unstructured.Unstructured, []types.Warning, error) { + panic("not implemented") +} + +func (m *mockStore) Delete(apiOp *types.APIRequest, schema *types.APISchema, id string) (*unstructured.Unstructured, []types.Warning, error) { + panic("not implemented") +} + +func (m *mockStore) Watch(apiOp *types.APIRequest, schema *types.APISchema, w types.WatchRequest) (chan watch.Event, error) { + panic("not implemented") +} + +var colorMap = map[string]string{ + "fuji": "pink", + "honeycrisp": "pink", + "granny-smith": "green", + "bramley": "green", + "crispin": "yellow", + "golden-delicious": "yellow", + "red-delicious": "red", +} + +func newRequest(query, username string) *types.APIRequest { + return &types.APIRequest{ + Request: (&http.Request{ + URL: &url.URL{ + Scheme: "https", + Host: "rancher", + Path: "/apples", + RawQuery: query, + }, + }).WithContext(request.WithUser(context.Background(), &user.DefaultInfo{ + Name: username, + Groups: []string{"system:authenticated"}, + })), + } +} + +type apple struct { + unstructured.Unstructured +} + +func newApple(name string) apple { + return apple{unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": name, + }, + "data": map[string]interface{}{ + "color": colorMap[name], + }, + }, + }} +} + +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: id, + Object: &a.Unstructured, + } +} + +func (a apple) with(data map[string]string) apple { + for k, v := range data { + a.Object["data"].(map[string]interface{})[k] = v + } + 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 +} + +func (m *mockAccessSetLookup) AccessFor(user user.Info) *accesscontrol.AccessSet { + userName := user.GetName() + access := getAccessID(userName, m.userRoles[0][userName]) + m.userRoles = m.userRoles[1:] + return &accesscontrol.AccessSet{ + ID: access, + } +} + +func (m *mockAccessSetLookup) PurgeUserData(_ string) { + panic("not implemented") +} + +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 generic.Indexer[*corev1.Namespace]) { + panic("not implemented") +} +func (m mockNamespaceCache) GetByIndex(indexName, key string) ([]*corev1.Namespace, error) { + panic("not implemented") +} diff --git a/pkg/stores/sqlproxy/dynamic_mocks_test.go b/pkg/stores/sqlproxy/dynamic_mocks_test.go new file mode 100644 index 00000000..38d37083 --- /dev/null +++ b/pkg/stores/sqlproxy/dynamic_mocks_test.go @@ -0,0 +1,232 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: k8s.io/client-go/dynamic (interfaces: ResourceInterface) + +// Package sqlproxy is a generated GoMock package. +package sqlproxy + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + unstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" +) + +// MockResourceInterface is a mock of ResourceInterface interface. +type MockResourceInterface struct { + ctrl *gomock.Controller + recorder *MockResourceInterfaceMockRecorder +} + +// MockResourceInterfaceMockRecorder is the mock recorder for MockResourceInterface. +type MockResourceInterfaceMockRecorder struct { + mock *MockResourceInterface +} + +// NewMockResourceInterface creates a new mock instance. +func NewMockResourceInterface(ctrl *gomock.Controller) *MockResourceInterface { + mock := &MockResourceInterface{ctrl: ctrl} + mock.recorder = &MockResourceInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockResourceInterface) EXPECT() *MockResourceInterfaceMockRecorder { + return m.recorder +} + +// Apply mocks base method. +func (m *MockResourceInterface) Apply(arg0 context.Context, arg1 string, arg2 *unstructured.Unstructured, arg3 v1.ApplyOptions, arg4 ...string) (*unstructured.Unstructured, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2, arg3} + for _, a := range arg4 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Apply", varargs...) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Apply indicates an expected call of Apply. +func (mr *MockResourceInterfaceMockRecorder) Apply(arg0, arg1, arg2, arg3 interface{}, arg4 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2, arg3}, arg4...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Apply", reflect.TypeOf((*MockResourceInterface)(nil).Apply), varargs...) +} + +// ApplyStatus mocks base method. +func (m *MockResourceInterface) ApplyStatus(arg0 context.Context, arg1 string, arg2 *unstructured.Unstructured, arg3 v1.ApplyOptions) (*unstructured.Unstructured, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ApplyStatus", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ApplyStatus indicates an expected call of ApplyStatus. +func (mr *MockResourceInterfaceMockRecorder) ApplyStatus(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyStatus", reflect.TypeOf((*MockResourceInterface)(nil).ApplyStatus), arg0, arg1, arg2, arg3) +} + +// Create mocks base method. +func (m *MockResourceInterface) Create(arg0 context.Context, arg1 *unstructured.Unstructured, arg2 v1.CreateOptions, arg3 ...string) (*unstructured.Unstructured, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2} + for _, a := range arg3 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Create", varargs...) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockResourceInterfaceMockRecorder) Create(arg0, arg1, arg2 interface{}, arg3 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2}, arg3...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockResourceInterface)(nil).Create), varargs...) +} + +// Delete mocks base method. +func (m *MockResourceInterface) Delete(arg0 context.Context, arg1 string, arg2 v1.DeleteOptions, arg3 ...string) error { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2} + for _, a := range arg3 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockResourceInterfaceMockRecorder) Delete(arg0, arg1, arg2 interface{}, arg3 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2}, arg3...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockResourceInterface)(nil).Delete), varargs...) +} + +// DeleteCollection mocks base method. +func (m *MockResourceInterface) DeleteCollection(arg0 context.Context, arg1 v1.DeleteOptions, arg2 v1.ListOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteCollection", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteCollection indicates an expected call of DeleteCollection. +func (mr *MockResourceInterfaceMockRecorder) DeleteCollection(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCollection", reflect.TypeOf((*MockResourceInterface)(nil).DeleteCollection), arg0, arg1, arg2) +} + +// Get mocks base method. +func (m *MockResourceInterface) Get(arg0 context.Context, arg1 string, arg2 v1.GetOptions, arg3 ...string) (*unstructured.Unstructured, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2} + for _, a := range arg3 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Get", varargs...) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockResourceInterfaceMockRecorder) Get(arg0, arg1, arg2 interface{}, arg3 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2}, arg3...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockResourceInterface)(nil).Get), varargs...) +} + +// List mocks base method. +func (m *MockResourceInterface) List(arg0 context.Context, arg1 v1.ListOptions) (*unstructured.UnstructuredList, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", arg0, arg1) + ret0, _ := ret[0].(*unstructured.UnstructuredList) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockResourceInterfaceMockRecorder) List(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockResourceInterface)(nil).List), arg0, arg1) +} + +// Patch mocks base method. +func (m *MockResourceInterface) Patch(arg0 context.Context, arg1 string, arg2 types.PatchType, arg3 []byte, arg4 v1.PatchOptions, arg5 ...string) (*unstructured.Unstructured, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2, arg3, arg4} + for _, a := range arg5 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Patch", varargs...) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Patch indicates an expected call of Patch. +func (mr *MockResourceInterfaceMockRecorder) Patch(arg0, arg1, arg2, arg3, arg4 interface{}, arg5 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2, arg3, arg4}, arg5...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockResourceInterface)(nil).Patch), varargs...) +} + +// Update mocks base method. +func (m *MockResourceInterface) Update(arg0 context.Context, arg1 *unstructured.Unstructured, arg2 v1.UpdateOptions, arg3 ...string) (*unstructured.Unstructured, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2} + for _, a := range arg3 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Update", varargs...) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockResourceInterfaceMockRecorder) Update(arg0, arg1, arg2 interface{}, arg3 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2}, arg3...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockResourceInterface)(nil).Update), varargs...) +} + +// UpdateStatus mocks base method. +func (m *MockResourceInterface) UpdateStatus(arg0 context.Context, arg1 *unstructured.Unstructured, arg2 v1.UpdateOptions) (*unstructured.Unstructured, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateStatus", arg0, arg1, arg2) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateStatus indicates an expected call of UpdateStatus. +func (mr *MockResourceInterfaceMockRecorder) UpdateStatus(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateStatus", reflect.TypeOf((*MockResourceInterface)(nil).UpdateStatus), arg0, arg1, arg2) +} + +// Watch mocks base method. +func (m *MockResourceInterface) Watch(arg0 context.Context, arg1 v1.ListOptions) (watch.Interface, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Watch", arg0, arg1) + ret0, _ := ret[0].(watch.Interface) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Watch indicates an expected call of Watch. +func (mr *MockResourceInterfaceMockRecorder) Watch(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Watch", reflect.TypeOf((*MockResourceInterface)(nil).Watch), arg0, arg1) +} diff --git a/pkg/stores/sqlproxy/proxy_mocks_test.go b/pkg/stores/sqlproxy/proxy_mocks_test.go new file mode 100644 index 00000000..e92836f4 --- /dev/null +++ b/pkg/stores/sqlproxy/proxy_mocks_test.go @@ -0,0 +1,360 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/rancher/steve/pkg/stores/sqlproxy (interfaces: Cache,ClientGetter,CacheFactory,SchemaColumnSetter,RelationshipNotifier) + +// Package sqlproxy is a generated GoMock package. +package sqlproxy + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + types "github.com/rancher/apiserver/pkg/types" + informer "github.com/rancher/lasso/pkg/cache/sql/informer" + factory "github.com/rancher/lasso/pkg/cache/sql/informer/factory" + partition "github.com/rancher/lasso/pkg/cache/sql/partition" + summary "github.com/rancher/wrangler/v3/pkg/summary" + unstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + schema "k8s.io/apimachinery/pkg/runtime/schema" + dynamic "k8s.io/client-go/dynamic" + kubernetes "k8s.io/client-go/kubernetes" + rest "k8s.io/client-go/rest" +) + +// MockCache is a mock of Cache interface. +type MockCache struct { + ctrl *gomock.Controller + recorder *MockCacheMockRecorder +} + +// MockCacheMockRecorder is the mock recorder for MockCache. +type MockCacheMockRecorder struct { + mock *MockCache +} + +// NewMockCache creates a new mock instance. +func NewMockCache(ctrl *gomock.Controller) *MockCache { + mock := &MockCache{ctrl: ctrl} + mock.recorder = &MockCacheMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCache) EXPECT() *MockCacheMockRecorder { + return m.recorder +} + +// ListByOptions mocks base method. +func (m *MockCache) ListByOptions(arg0 context.Context, arg1 informer.ListOptions, arg2 []partition.Partition, arg3 string) (*unstructured.UnstructuredList, int, string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListByOptions", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*unstructured.UnstructuredList) + ret1, _ := ret[1].(int) + ret2, _ := ret[2].(string) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 +} + +// ListByOptions indicates an expected call of ListByOptions. +func (mr *MockCacheMockRecorder) ListByOptions(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByOptions", reflect.TypeOf((*MockCache)(nil).ListByOptions), arg0, arg1, arg2, arg3) +} + +// MockClientGetter is a mock of ClientGetter interface. +type MockClientGetter struct { + ctrl *gomock.Controller + recorder *MockClientGetterMockRecorder +} + +// MockClientGetterMockRecorder is the mock recorder for MockClientGetter. +type MockClientGetterMockRecorder struct { + mock *MockClientGetter +} + +// NewMockClientGetter creates a new mock instance. +func NewMockClientGetter(ctrl *gomock.Controller) *MockClientGetter { + mock := &MockClientGetter{ctrl: ctrl} + mock.recorder = &MockClientGetterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClientGetter) EXPECT() *MockClientGetterMockRecorder { + return m.recorder +} + +// AdminClient mocks base method. +func (m *MockClientGetter) AdminClient(arg0 *types.APIRequest, arg1 *types.APISchema, arg2 string, arg3 rest.WarningHandler) (dynamic.ResourceInterface, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AdminClient", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(dynamic.ResourceInterface) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AdminClient indicates an expected call of AdminClient. +func (mr *MockClientGetterMockRecorder) AdminClient(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AdminClient", reflect.TypeOf((*MockClientGetter)(nil).AdminClient), arg0, arg1, arg2, arg3) +} + +// AdminK8sInterface mocks base method. +func (m *MockClientGetter) AdminK8sInterface() (kubernetes.Interface, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AdminK8sInterface") + ret0, _ := ret[0].(kubernetes.Interface) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AdminK8sInterface indicates an expected call of AdminK8sInterface. +func (mr *MockClientGetterMockRecorder) AdminK8sInterface() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AdminK8sInterface", reflect.TypeOf((*MockClientGetter)(nil).AdminK8sInterface)) +} + +// Client mocks base method. +func (m *MockClientGetter) Client(arg0 *types.APIRequest, arg1 *types.APISchema, arg2 string, arg3 rest.WarningHandler) (dynamic.ResourceInterface, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Client", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(dynamic.ResourceInterface) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Client indicates an expected call of Client. +func (mr *MockClientGetterMockRecorder) Client(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Client", reflect.TypeOf((*MockClientGetter)(nil).Client), arg0, arg1, arg2, arg3) +} + +// DynamicClient mocks base method. +func (m *MockClientGetter) DynamicClient(arg0 *types.APIRequest, arg1 rest.WarningHandler) (dynamic.Interface, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DynamicClient", arg0, arg1) + ret0, _ := ret[0].(dynamic.Interface) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DynamicClient indicates an expected call of DynamicClient. +func (mr *MockClientGetterMockRecorder) DynamicClient(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DynamicClient", reflect.TypeOf((*MockClientGetter)(nil).DynamicClient), arg0, arg1) +} + +// IsImpersonating mocks base method. +func (m *MockClientGetter) IsImpersonating() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsImpersonating") + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsImpersonating indicates an expected call of IsImpersonating. +func (mr *MockClientGetterMockRecorder) IsImpersonating() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsImpersonating", reflect.TypeOf((*MockClientGetter)(nil).IsImpersonating)) +} + +// K8sInterface mocks base method. +func (m *MockClientGetter) K8sInterface(arg0 *types.APIRequest) (kubernetes.Interface, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "K8sInterface", arg0) + ret0, _ := ret[0].(kubernetes.Interface) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// K8sInterface indicates an expected call of K8sInterface. +func (mr *MockClientGetterMockRecorder) K8sInterface(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "K8sInterface", reflect.TypeOf((*MockClientGetter)(nil).K8sInterface), arg0) +} + +// TableAdminClient mocks base method. +func (m *MockClientGetter) TableAdminClient(arg0 *types.APIRequest, arg1 *types.APISchema, arg2 string, arg3 rest.WarningHandler) (dynamic.ResourceInterface, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TableAdminClient", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(dynamic.ResourceInterface) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TableAdminClient indicates an expected call of TableAdminClient. +func (mr *MockClientGetterMockRecorder) TableAdminClient(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TableAdminClient", reflect.TypeOf((*MockClientGetter)(nil).TableAdminClient), arg0, arg1, arg2, arg3) +} + +// TableAdminClientForWatch mocks base method. +func (m *MockClientGetter) TableAdminClientForWatch(arg0 *types.APIRequest, arg1 *types.APISchema, arg2 string, arg3 rest.WarningHandler) (dynamic.ResourceInterface, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TableAdminClientForWatch", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(dynamic.ResourceInterface) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TableAdminClientForWatch indicates an expected call of TableAdminClientForWatch. +func (mr *MockClientGetterMockRecorder) TableAdminClientForWatch(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TableAdminClientForWatch", reflect.TypeOf((*MockClientGetter)(nil).TableAdminClientForWatch), arg0, arg1, arg2, arg3) +} + +// TableClient mocks base method. +func (m *MockClientGetter) TableClient(arg0 *types.APIRequest, arg1 *types.APISchema, arg2 string, arg3 rest.WarningHandler) (dynamic.ResourceInterface, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TableClient", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(dynamic.ResourceInterface) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TableClient indicates an expected call of TableClient. +func (mr *MockClientGetterMockRecorder) TableClient(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TableClient", reflect.TypeOf((*MockClientGetter)(nil).TableClient), arg0, arg1, arg2, arg3) +} + +// TableClientForWatch mocks base method. +func (m *MockClientGetter) TableClientForWatch(arg0 *types.APIRequest, arg1 *types.APISchema, arg2 string, arg3 rest.WarningHandler) (dynamic.ResourceInterface, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TableClientForWatch", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(dynamic.ResourceInterface) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TableClientForWatch indicates an expected call of TableClientForWatch. +func (mr *MockClientGetterMockRecorder) TableClientForWatch(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TableClientForWatch", reflect.TypeOf((*MockClientGetter)(nil).TableClientForWatch), arg0, arg1, arg2, arg3) +} + +// MockCacheFactory is a mock of CacheFactory interface. +type MockCacheFactory struct { + ctrl *gomock.Controller + recorder *MockCacheFactoryMockRecorder +} + +// MockCacheFactoryMockRecorder is the mock recorder for MockCacheFactory. +type MockCacheFactoryMockRecorder struct { + mock *MockCacheFactory +} + +// NewMockCacheFactory creates a new mock instance. +func NewMockCacheFactory(ctrl *gomock.Controller) *MockCacheFactory { + mock := &MockCacheFactory{ctrl: ctrl} + mock.recorder = &MockCacheFactoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCacheFactory) EXPECT() *MockCacheFactoryMockRecorder { + return m.recorder +} + +// CacheFor mocks base method. +func (m *MockCacheFactory) CacheFor(arg0 [][]string, arg1 dynamic.ResourceInterface, arg2 schema.GroupVersionKind, arg3 bool) (factory.Cache, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CacheFor", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(factory.Cache) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CacheFor indicates an expected call of CacheFor. +func (mr *MockCacheFactoryMockRecorder) CacheFor(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CacheFor", reflect.TypeOf((*MockCacheFactory)(nil).CacheFor), arg0, arg1, arg2, arg3) +} + +// Reset mocks base method. +func (m *MockCacheFactory) Reset() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Reset") + ret0, _ := ret[0].(error) + return ret0 +} + +// Reset indicates an expected call of Reset. +func (mr *MockCacheFactoryMockRecorder) Reset() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Reset", reflect.TypeOf((*MockCacheFactory)(nil).Reset)) +} + +// MockSchemaColumnSetter is a mock of SchemaColumnSetter interface. +type MockSchemaColumnSetter struct { + ctrl *gomock.Controller + recorder *MockSchemaColumnSetterMockRecorder +} + +// MockSchemaColumnSetterMockRecorder is the mock recorder for MockSchemaColumnSetter. +type MockSchemaColumnSetterMockRecorder struct { + mock *MockSchemaColumnSetter +} + +// NewMockSchemaColumnSetter creates a new mock instance. +func NewMockSchemaColumnSetter(ctrl *gomock.Controller) *MockSchemaColumnSetter { + mock := &MockSchemaColumnSetter{ctrl: ctrl} + mock.recorder = &MockSchemaColumnSetterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSchemaColumnSetter) EXPECT() *MockSchemaColumnSetterMockRecorder { + return m.recorder +} + +// SetColumns mocks base method. +func (m *MockSchemaColumnSetter) SetColumns(arg0 context.Context, arg1 *types.APISchema) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetColumns", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetColumns indicates an expected call of SetColumns. +func (mr *MockSchemaColumnSetterMockRecorder) SetColumns(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetColumns", reflect.TypeOf((*MockSchemaColumnSetter)(nil).SetColumns), arg0, arg1) +} + +// MockRelationshipNotifier is a mock of RelationshipNotifier interface. +type MockRelationshipNotifier struct { + ctrl *gomock.Controller + recorder *MockRelationshipNotifierMockRecorder +} + +// MockRelationshipNotifierMockRecorder is the mock recorder for MockRelationshipNotifier. +type MockRelationshipNotifierMockRecorder struct { + mock *MockRelationshipNotifier +} + +// NewMockRelationshipNotifier creates a new mock instance. +func NewMockRelationshipNotifier(ctrl *gomock.Controller) *MockRelationshipNotifier { + mock := &MockRelationshipNotifier{ctrl: ctrl} + mock.recorder = &MockRelationshipNotifierMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRelationshipNotifier) EXPECT() *MockRelationshipNotifierMockRecorder { + return m.recorder +} + +// OnInboundRelationshipChange mocks base method. +func (m *MockRelationshipNotifier) OnInboundRelationshipChange(arg0 context.Context, arg1 *types.APISchema, arg2 string) <-chan *summary.Relationship { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OnInboundRelationshipChange", arg0, arg1, arg2) + ret0, _ := ret[0].(<-chan *summary.Relationship) + return ret0 +} + +// OnInboundRelationshipChange indicates an expected call of OnInboundRelationshipChange. +func (mr *MockRelationshipNotifierMockRecorder) OnInboundRelationshipChange(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnInboundRelationshipChange", reflect.TypeOf((*MockRelationshipNotifier)(nil).OnInboundRelationshipChange), arg0, arg1, arg2) +} diff --git a/pkg/stores/sqlproxy/proxy_store.go b/pkg/stores/sqlproxy/proxy_store.go new file mode 100644 index 00000000..319bda83 --- /dev/null +++ b/pkg/stores/sqlproxy/proxy_store.go @@ -0,0 +1,691 @@ +// Package sqlproxy implements the proxy store, which is responsible for either interfacing directly with the Kubernetes API, +// or in the case of List, interfacing with an on-disk cache of items in the Kubernetes API. +package sqlproxy + +import ( + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "strconv" + "strings" + "sync" + + "github.com/pkg/errors" + "github.com/rancher/apiserver/pkg/apierror" + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/lasso/pkg/cache/sql/informer" + "github.com/rancher/lasso/pkg/cache/sql/informer/factory" + "github.com/rancher/lasso/pkg/cache/sql/partition" + "github.com/rancher/steve/pkg/attributes" + "github.com/rancher/steve/pkg/resources/common" + metricsStore "github.com/rancher/steve/pkg/stores/metrics" + "github.com/rancher/steve/pkg/stores/sqlpartition/listprocessor" + "github.com/rancher/steve/pkg/stores/sqlproxy/tablelistconvert" + "github.com/rancher/wrangler/v3/pkg/data" + "github.com/rancher/wrangler/v3/pkg/schemas" + "github.com/rancher/wrangler/v3/pkg/schemas/validation" + "github.com/rancher/wrangler/v3/pkg/summary" + "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + apitypes "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +const watchTimeoutEnv = "CATTLE_WATCH_TIMEOUT_SECONDS" + +var ( + paramScheme = runtime.NewScheme() + paramCodec = runtime.NewParameterCodec(paramScheme) + typeSpecificIndexedFields = map[string][][]string{ + "_v1_Namespace": {{`metadata`, `labels[field.cattle.io/projectId]`}}, + "_v1_Node": {{`status`, `nodeInfo`, `kubeletVersion`}, {`status`, `nodeInfo`, `operatingSystem`}}, + "_v1_Pod": {{`spec`, `containers`, `image`}, {`spec`, `nodeName`}}, + "_v1_ConfigMap": {{`metadata`, `labels[harvesterhci.io/cloud-init-template]`}}, + + "management.cattle.io_v3_Node": {{`status`, `nodeName`}}, + } + baseNSSchema = types.APISchema{ + Schema: &schemas.Schema{ + Attributes: map[string]interface{}{ + "group": "", + "version": "v1", + "kind": "Namespace", + "resource": "namespaces", + }, + }, + } +) + +func init() { + metav1.AddToGroupVersion(paramScheme, metav1.SchemeGroupVersion) +} + +// ClientGetter is a dynamic kubernetes client factory. +type ClientGetter interface { + IsImpersonating() bool + K8sInterface(ctx *types.APIRequest) (kubernetes.Interface, error) + AdminK8sInterface() (kubernetes.Interface, error) + Client(ctx *types.APIRequest, schema *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) + DynamicClient(ctx *types.APIRequest, warningHandler rest.WarningHandler) (dynamic.Interface, error) + AdminClient(ctx *types.APIRequest, schema *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) + TableClient(ctx *types.APIRequest, schema *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) + TableAdminClient(ctx *types.APIRequest, schema *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) + TableClientForWatch(ctx *types.APIRequest, schema *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) + TableAdminClientForWatch(ctx *types.APIRequest, schema *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) +} + +type SchemaColumnSetter interface { + SetColumns(ctx context.Context, schema *types.APISchema) error +} + +type Cache interface { + // ListByOptions returns objects according to the specified list options and partitions. + // Specifically: + // - an unstructured list of resources belonging to any of the specified partitions + // - the total number of resources (returned list might be a subset depending on pagination options in lo) + // - a continue token, if there are more pages after the returned one + // - an error instead of all of the above if anything went wrong + ListByOptions(ctx context.Context, lo informer.ListOptions, partitions []partition.Partition, namespace string) (*unstructured.UnstructuredList, int, string, error) +} + +// WarningBuffer holds warnings that may be returned from the kubernetes api +type WarningBuffer []types.Warning + +// HandleWarningHeader takes the components of a kubernetes warning header and stores them +func (w *WarningBuffer) HandleWarningHeader(code int, agent string, text string) { + *w = append(*w, types.Warning{ + Code: code, + Agent: agent, + Text: text, + }) +} + +// RelationshipNotifier is an interface for handling wrangler summary.Relationship events. +type RelationshipNotifier interface { + OnInboundRelationshipChange(ctx context.Context, schema *types.APISchema, namespace string) <-chan *summary.Relationship +} + +type Store struct { + clientGetter ClientGetter + notifier RelationshipNotifier + cacheFactory CacheFactory + cfInitializer CacheFactoryInitializer + namespaceCache Cache + lock sync.Mutex + columnSetter SchemaColumnSetter +} + +type CacheFactoryInitializer func() (CacheFactory, error) + +type CacheFactory interface { + CacheFor(fields [][]string, client dynamic.ResourceInterface, gvk schema.GroupVersionKind, namespaced bool) (factory.Cache, error) + Reset() error +} + +// NewProxyStore returns a Store implemented directly on top of kubernetes. +func NewProxyStore(c SchemaColumnSetter, clientGetter ClientGetter, notifier RelationshipNotifier, factory CacheFactory) (*Store, error) { + store := &Store{ + clientGetter: clientGetter, + notifier: notifier, + columnSetter: c, + } + + if factory == nil { + var err error + factory, err = defaultInitializeCacheFactory() + if err != nil { + return nil, err + } + } + + store.cacheFactory = factory + if err := store.initializeNamespaceCache(); err != nil { + logrus.Infof("failed to warm up namespace informer for proxy store in steve, will try again on next ns request") + } + return store, nil +} + +// Reset locks the store, resets the underlying cache factory, and warm the namespace cache. +func (s *Store) Reset() error { + s.lock.Lock() + defer s.lock.Unlock() + if err := s.cacheFactory.Reset(); err != nil { + return err + } + + if err := s.initializeNamespaceCache(); err != nil { + return err + } + return nil +} + +func defaultInitializeCacheFactory() (CacheFactory, error) { + informerFactory, err := factory.NewCacheFactory() + if err != nil { + return nil, err + } + return informerFactory, nil +} + +// initializeNamespaceCache warms up the namespace cache as it is needed to process queries using options related to +// namespaces and projects. +func (s *Store) initializeNamespaceCache() error { + buffer := WarningBuffer{} + nsSchema := baseNSSchema + + // make sure any relevant columns are set to the ns schema + if err := s.columnSetter.SetColumns(context.Background(), &nsSchema); err != nil { + return fmt.Errorf("failed to set columns for proxy stores namespace informer: %w", err) + } + + // build table client + client, err := s.clientGetter.TableAdminClient(nil, &nsSchema, "", &buffer) + if err != nil { + return err + } + + // get fields from schema's columns + fields := getFieldsFromSchema(&nsSchema) + + // get any type-specific fields that steve is interested in + fields = append(fields, getFieldForGVK(attributes.GVK(&nsSchema))...) + + // get the ns informer + nsInformer, err := s.cacheFactory.CacheFor(fields, &tablelistconvert.Client{ResourceInterface: client}, attributes.GVK(&nsSchema), false) + if err != nil { + return err + } + + s.namespaceCache = nsInformer + return nil +} + +func getFieldForGVK(gvk schema.GroupVersionKind) [][]string { + return typeSpecificIndexedFields[keyFromGVK(gvk)] +} + +func keyFromGVK(gvk schema.GroupVersionKind) string { + return gvk.Group + "_" + gvk.Version + "_" + gvk.Kind +} + +// getFieldsFromSchema converts object field names from types.APISchema's format into lasso's +// cache.sql.informer's slice format (e.g. "metadata.resourceVersion" is ["metadata", "resourceVersion"]) +func getFieldsFromSchema(schema *types.APISchema) [][]string { + var fields [][]string + columns := attributes.Columns(schema) + if columns == nil { + return nil + } + colDefs, ok := columns.([]common.ColumnDefinition) + if !ok { + return nil + } + for _, colDef := range colDefs { + field := strings.TrimPrefix(colDef.Field, "$.") + fields = append(fields, strings.Split(field, ".")) + } + return fields +} + +// ByID looks up a single object by its ID. +func (s *Store) ByID(apiOp *types.APIRequest, schema *types.APISchema, id string) (*unstructured.Unstructured, []types.Warning, error) { + return s.byID(apiOp, schema, apiOp.Namespace, id) +} + +func decodeParams(apiOp *types.APIRequest, target runtime.Object) error { + return paramCodec.DecodeParameters(apiOp.Request.URL.Query(), metav1.SchemeGroupVersion, target) +} + +func (s *Store) byID(apiOp *types.APIRequest, schema *types.APISchema, namespace, id string) (*unstructured.Unstructured, []types.Warning, error) { + buffer := WarningBuffer{} + k8sClient, err := metricsStore.Wrap(s.clientGetter.TableClient(apiOp, schema, namespace, &buffer)) + if err != nil { + return nil, nil, err + } + + opts := metav1.GetOptions{} + if err := decodeParams(apiOp, &opts); err != nil { + return nil, nil, err + } + + obj, err := k8sClient.Get(apiOp, id, opts) + rowToObject(obj) + return obj, buffer, err +} + +func moveFromUnderscore(obj map[string]interface{}) map[string]interface{} { + if obj == nil { + return nil + } + for k := range types.ReservedFields { + v, ok := obj["_"+k] + delete(obj, "_"+k) + delete(obj, k) + if ok { + obj[k] = v + } + } + return obj +} + +func rowToObject(obj *unstructured.Unstructured) { + if obj == nil { + return + } + if obj.Object["kind"] != "Table" || + (obj.Object["apiVersion"] != "meta.k8s.io/v1" && + obj.Object["apiVersion"] != "meta.k8s.io/v1beta1") { + return + } + + items := tableToObjects(obj.Object) + if len(items) == 1 { + obj.Object = items[0].Object + } +} + +func tableToObjects(obj map[string]interface{}) []unstructured.Unstructured { + var result []unstructured.Unstructured + + rows, _ := obj["rows"].([]interface{}) + for _, row := range rows { + m, ok := row.(map[string]interface{}) + if !ok { + continue + } + cells := m["cells"] + object, ok := m["object"].(map[string]interface{}) + if !ok { + continue + } + + data.PutValue(object, cells, "metadata", "fields") + result = append(result, unstructured.Unstructured{ + Object: object, + }) + } + + return result +} + +func returnErr(err error, c chan watch.Event) { + c <- watch.Event{ + Type: watch.Error, + Object: &metav1.Status{ + Message: err.Error(), + }, + } +} + +func (s *Store) listAndWatch(apiOp *types.APIRequest, client dynamic.ResourceInterface, schema *types.APISchema, w types.WatchRequest, result chan watch.Event) { + rev := w.Revision + if rev == "-1" || rev == "0" { + rev = "" + } + + timeout := int64(60 * 30) + timeoutSetting := os.Getenv(watchTimeoutEnv) + if timeoutSetting != "" { + userSetTimeout, err := strconv.Atoi(timeoutSetting) + if err != nil { + logrus.Debugf("could not parse %s environment variable, error: %v", watchTimeoutEnv, err) + } else { + timeout = int64(userSetTimeout) + } + } + k8sClient, _ := metricsStore.Wrap(client, nil) + watcher, err := k8sClient.Watch(apiOp, metav1.ListOptions{ + Watch: true, + TimeoutSeconds: &timeout, + ResourceVersion: rev, + LabelSelector: w.Selector, + }) + if err != nil { + returnErr(errors.Wrapf(err, "stopping watch for %s: %v", schema.ID, err), result) + return + } + defer watcher.Stop() + logrus.Debugf("opening watcher for %s", schema.ID) + + eg, ctx := errgroup.WithContext(apiOp.Context()) + + go func() { + <-ctx.Done() + watcher.Stop() + }() + + if s.notifier != nil { + eg.Go(func() error { + for rel := range s.notifier.OnInboundRelationshipChange(ctx, schema, apiOp.Namespace) { + obj, _, err := s.byID(apiOp, schema, rel.Namespace, rel.Name) + if err == nil { + rowToObject(obj) + result <- watch.Event{Type: watch.Modified, Object: obj} + } else { + returnErr(errors.Wrapf(err, "notifier watch error: %v", err), result) + } + } + return fmt.Errorf("closed") + }) + } + + eg.Go(func() error { + for event := range watcher.ResultChan() { + if event.Type == watch.Error { + if status, ok := event.Object.(*metav1.Status); ok { + returnErr(fmt.Errorf("event watch error: %s", status.Message), result) + } else { + logrus.Debugf("event watch error: could not decode event object %T", event.Object) + } + continue + } + if unstr, ok := event.Object.(*unstructured.Unstructured); ok { + rowToObject(unstr) + } + result <- event + } + return fmt.Errorf("closed") + }) + + _ = eg.Wait() + return +} + +// WatchNames returns a channel of events filtered by an allowed set of names. +// In plain kubernetes, if a user has permission to 'list' or 'watch' a defined set of resource names, +// performing the list or watch will result in a Forbidden error, because the user does not have permission +// to list *all* resources. +// With this filter, the request can be performed successfully, and only the allowed resources will +// be returned in watch. +func (s *Store) WatchNames(apiOp *types.APIRequest, schema *types.APISchema, w types.WatchRequest, names sets.Set[string]) (chan watch.Event, error) { + buffer := &WarningBuffer{} + adminClient, err := s.clientGetter.TableAdminClientForWatch(apiOp, schema, apiOp.Namespace, buffer) + if err != nil { + return nil, err + } + c, err := s.watch(apiOp, schema, w, adminClient) + if err != nil { + return nil, err + } + + result := make(chan watch.Event) + go func() { + defer close(result) + for item := range c { + if item.Type == watch.Error { + if status, ok := item.Object.(*metav1.Status); ok { + logrus.Debugf("WatchNames received error: %s", status.Message) + } else { + logrus.Debugf("WatchNames received error: %v", item) + } + result <- item + continue + } + + m, err := meta.Accessor(item.Object) + if err != nil { + logrus.Debugf("WatchNames cannot process unexpected object: %s", err) + continue + } + + if names.Has(m.GetName()) { + result <- item + } + } + }() + + return result, nil +} + +// Watch returns a channel of events for a list or resource. +func (s *Store) Watch(apiOp *types.APIRequest, schema *types.APISchema, w types.WatchRequest) (chan watch.Event, error) { + buffer := &WarningBuffer{} + client, err := s.clientGetter.TableClientForWatch(apiOp, schema, apiOp.Namespace, buffer) + if err != nil { + return nil, err + } + return s.watch(apiOp, schema, w, client) +} + +func (s *Store) watch(apiOp *types.APIRequest, schema *types.APISchema, w types.WatchRequest, client dynamic.ResourceInterface) (chan watch.Event, error) { + result := make(chan watch.Event) + go func() { + s.listAndWatch(apiOp, client, schema, w, result) + logrus.Debugf("closing watcher for %s", schema.ID) + close(result) + }() + return result, nil +} + +// Create creates a single object in the store. +func (s *Store) Create(apiOp *types.APIRequest, schema *types.APISchema, params types.APIObject) (*unstructured.Unstructured, []types.Warning, error) { + var ( + resp *unstructured.Unstructured + ) + + input := params.Data() + + if input == nil { + input = data.Object{} + } + + name := types.Name(input) + ns := types.Namespace(input) + if name == "" && input.String("metadata", "generateName") == "" { + input.SetNested(schema.ID[0:1]+"-", "metadata", "generatedName") + } + if ns == "" && apiOp.Namespace != "" { + ns = apiOp.Namespace + input.SetNested(ns, "metadata", "namespace") + } + + gvk := attributes.GVK(schema) + input["apiVersion"], input["kind"] = gvk.ToAPIVersionAndKind() + + buffer := WarningBuffer{} + k8sClient, err := metricsStore.Wrap(s.clientGetter.TableClient(apiOp, schema, ns, &buffer)) + if err != nil { + return nil, nil, err + } + + opts := metav1.CreateOptions{} + if err := decodeParams(apiOp, &opts); err != nil { + return nil, nil, err + } + + resp, err = k8sClient.Create(apiOp, &unstructured.Unstructured{Object: input}, opts) + rowToObject(resp) + return resp, buffer, err +} + +// Update updates a single object in the store. +func (s *Store) Update(apiOp *types.APIRequest, schema *types.APISchema, params types.APIObject, id string) (*unstructured.Unstructured, []types.Warning, error) { + var ( + err error + input = params.Data() + ) + + ns := types.Namespace(input) + buffer := WarningBuffer{} + k8sClient, err := metricsStore.Wrap(s.clientGetter.TableClient(apiOp, schema, ns, &buffer)) + if err != nil { + return nil, nil, err + } + + if apiOp.Method == http.MethodPatch { + bytes, err := ioutil.ReadAll(io.LimitReader(apiOp.Request.Body, 2<<20)) + if err != nil { + return nil, nil, err + } + + pType := apitypes.StrategicMergePatchType + if apiOp.Request.Header.Get("content-type") == string(apitypes.JSONPatchType) { + pType = apitypes.JSONPatchType + } + + opts := metav1.PatchOptions{} + if err := decodeParams(apiOp, &opts); err != nil { + return nil, nil, err + } + + if pType == apitypes.StrategicMergePatchType { + data := map[string]interface{}{} + if err := json.Unmarshal(bytes, &data); err != nil { + return nil, nil, err + } + data = moveFromUnderscore(data) + bytes, err = json.Marshal(data) + if err != nil { + return nil, nil, err + } + } + + resp, err := k8sClient.Patch(apiOp, id, pType, bytes, opts) + if err != nil { + return nil, nil, err + } + + return resp, buffer, nil + } + + resourceVersion := input.String("metadata", "resourceVersion") + if resourceVersion == "" { + return nil, nil, fmt.Errorf("metadata.resourceVersion is required for update") + } + + opts := metav1.UpdateOptions{} + if err := decodeParams(apiOp, &opts); err != nil { + return nil, nil, err + } + + resp, err := k8sClient.Update(apiOp, &unstructured.Unstructured{Object: moveFromUnderscore(input)}, metav1.UpdateOptions{}) + if err != nil { + return nil, nil, err + } + + rowToObject(resp) + return resp, buffer, nil +} + +// Delete deletes an object from a store. +func (s *Store) Delete(apiOp *types.APIRequest, schema *types.APISchema, id string) (*unstructured.Unstructured, []types.Warning, error) { + opts := metav1.DeleteOptions{} + if err := decodeParams(apiOp, &opts); err != nil { + return nil, nil, nil + } + + buffer := WarningBuffer{} + k8sClient, err := metricsStore.Wrap(s.clientGetter.TableClient(apiOp, schema, apiOp.Namespace, &buffer)) + if err != nil { + return nil, nil, err + } + + if err := k8sClient.Delete(apiOp, id, opts); err != nil { + return nil, nil, err + } + + obj, _, err := s.byID(apiOp, schema, apiOp.Namespace, id) + if err != nil { + // ignore lookup error + return nil, nil, validation.ErrorCode{ + Status: http.StatusNoContent, + } + } + return obj, buffer, nil +} + +// ListByPartitions returns: +// - an unstructured list of resources belonging to any of the specified partitions +// - the total number of resources (returned list might be a subset depending on pagination options in apiOp) +// - a continue token, if there are more pages after the returned one +// - an error instead of all of the above if anything went wrong +func (s *Store) ListByPartitions(apiOp *types.APIRequest, schema *types.APISchema, partitions []partition.Partition) ([]unstructured.Unstructured, int, string, error) { + opts, err := listprocessor.ParseQuery(apiOp, s.namespaceCache) + if err != nil { + return nil, 0, "", err + } + // warnings from inside the informer are discarded + buffer := WarningBuffer{} + client, err := s.clientGetter.TableAdminClient(apiOp, schema, "", &buffer) + if err != nil { + return nil, 0, "", err + } + fields := getFieldsFromSchema(schema) + fields = append(fields, getFieldForGVK(attributes.GVK(schema))...) + + inf, err := s.cacheFactory.CacheFor(fields, &tablelistconvert.Client{ResourceInterface: client}, attributes.GVK(schema), attributes.Namespaced(schema)) + if err != nil { + return nil, 0, "", err + } + + list, total, continueToken, err := inf.ListByOptions(apiOp.Context(), opts, partitions, apiOp.Namespace) + if err != nil { + if errors.Is(err, informer.InvalidColumnErr) { + return nil, 0, "", apierror.NewAPIError(validation.InvalidBodyContent, err.Error()) + } + return nil, 0, "", err + } + + return list.Items, total, continueToken, nil +} + +// WatchByPartitions returns a channel of events for a list or resource belonging to any of the specified partitions +func (s *Store) WatchByPartitions(apiOp *types.APIRequest, schema *types.APISchema, wr types.WatchRequest, partitions []partition.Partition) (chan watch.Event, error) { + ctx, cancel := context.WithCancel(apiOp.Context()) + apiOp = apiOp.Clone().WithContext(ctx) + + eg := errgroup.Group{} + + result := make(chan watch.Event) + + for _, partition := range partitions { + p := partition + eg.Go(func() error { + defer cancel() + c, err := s.watchByPartition(p, apiOp, schema, wr) + + if err != nil { + return err + } + for i := range c { + result <- i + } + return nil + }) + } + + go func() { + defer close(result) + <-ctx.Done() + eg.Wait() + cancel() + }() + + return result, nil +} + +// watchByPartition returns a channel of events for a list or resource belonging to a specified partition +func (s *Store) watchByPartition(partition partition.Partition, apiOp *types.APIRequest, schema *types.APISchema, wr types.WatchRequest) (chan watch.Event, error) { + if partition.Passthrough { + return s.Watch(apiOp, schema, wr) + } + + apiOp.Namespace = partition.Namespace + if partition.All { + return s.Watch(apiOp, schema, wr) + } + return s.WatchNames(apiOp, schema, wr, partition.Names) +} diff --git a/pkg/stores/sqlproxy/proxy_store_test.go b/pkg/stores/sqlproxy/proxy_store_test.go new file mode 100644 index 00000000..1df618db --- /dev/null +++ b/pkg/stores/sqlproxy/proxy_store_test.go @@ -0,0 +1,719 @@ +package sqlproxy + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/rancher/lasso/pkg/cache/sql/informer" + "github.com/rancher/lasso/pkg/cache/sql/informer/factory" + "github.com/rancher/lasso/pkg/cache/sql/partition" + "github.com/rancher/steve/pkg/attributes" + "github.com/rancher/steve/pkg/resources/common" + "github.com/rancher/steve/pkg/stores/sqlpartition/listprocessor" + "github.com/rancher/steve/pkg/stores/sqlproxy/tablelistconvert" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/pkg/errors" + "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/steve/pkg/client" + "github.com/rancher/wrangler/v3/pkg/schemas" + "github.com/stretchr/testify/assert" + "golang.org/x/sync/errgroup" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + schema2 "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/rest" + clientgotesting "k8s.io/client-go/testing" +) + +//go:generate mockgen --build_flags=--mod=mod -package sqlproxy -destination ./proxy_mocks_test.go github.com/rancher/steve/pkg/stores/sqlproxy Cache,ClientGetter,CacheFactory,SchemaColumnSetter,RelationshipNotifier +//go:generate mockgen --build_flags=--mod=mod -package sqlproxy -destination ./sql_informer_mocks_test.go github.com/rancher/lasso/pkg/cache/sql/informer ByOptionsLister +//go:generate mockgen --build_flags=--mod=mod -package sqlproxy -destination ./dynamic_mocks_test.go k8s.io/client-go/dynamic ResourceInterface + +var c *watch.FakeWatcher + +type testFactory struct { + *client.Factory + + fakeClient *fake.FakeDynamicClient +} + +func TestNewProxyStore(t *testing.T) { + type testCase struct { + description string + test func(t *testing.T) + } + var tests []testCase + tests = append(tests, testCase{ + description: "NewProxyStore() with no errors returned should returned no errors. Should initialize and assign" + + " a namespace cache.", + test: func(t *testing.T) { + scc := NewMockSchemaColumnSetter(gomock.NewController(t)) + cg := NewMockClientGetter(gomock.NewController(t)) + rn := NewMockRelationshipNotifier(gomock.NewController(t)) + cf := NewMockCacheFactory(gomock.NewController(t)) + ri := NewMockResourceInterface(gomock.NewController(t)) + bloi := NewMockByOptionsLister(gomock.NewController(t)) + c := factory.Cache{ + ByOptionsLister: &informer.Informer{ + ByOptionsLister: bloi, + }, + } + + nsSchema := baseNSSchema + scc.EXPECT().SetColumns(context.Background(), &nsSchema).Return(nil) + cg.EXPECT().TableAdminClient(nil, &nsSchema, "", &WarningBuffer{}).Return(ri, nil) + cf.EXPECT().CacheFor([][]string{{"metadata", "labels[field.cattle.io/projectId]"}}, &tablelistconvert.Client{ResourceInterface: ri}, attributes.GVK(&nsSchema), false).Return(c, nil) + + s, err := NewProxyStore(scc, cg, rn, cf) + assert.Nil(t, err) + assert.Equal(t, scc, s.columnSetter) + assert.Equal(t, cg, s.clientGetter) + assert.Equal(t, rn, s.notifier) + assert.Equal(t, s.cacheFactory, cf) + assert.NotNil(t, s.namespaceCache) + }, + }) + tests = append(tests, testCase{ + description: "NewProxyStore() with schema column setter SetColumns() error returned should return not return and error" + + " and not set namespace cache.", + test: func(t *testing.T) { + scc := NewMockSchemaColumnSetter(gomock.NewController(t)) + cg := NewMockClientGetter(gomock.NewController(t)) + rn := NewMockRelationshipNotifier(gomock.NewController(t)) + cf := NewMockCacheFactory(gomock.NewController(t)) + + nsSchema := baseNSSchema + scc.EXPECT().SetColumns(context.Background(), &nsSchema).Return(fmt.Errorf("error")) + + s, err := NewProxyStore(scc, cg, rn, cf) + assert.Nil(t, err) + assert.Equal(t, scc, s.columnSetter) + assert.Equal(t, cg, s.clientGetter) + assert.Equal(t, rn, s.notifier) + assert.Equal(t, s.cacheFactory, cf) + assert.Nil(t, s.namespaceCache) + }, + }) + tests = append(tests, testCase{ + description: "NewProxyStore() with client getter TableAdminClient() error returned should return not return and error" + + " and not set namespace cache.", + test: func(t *testing.T) { + scc := NewMockSchemaColumnSetter(gomock.NewController(t)) + cg := NewMockClientGetter(gomock.NewController(t)) + rn := NewMockRelationshipNotifier(gomock.NewController(t)) + cf := NewMockCacheFactory(gomock.NewController(t)) + + nsSchema := baseNSSchema + scc.EXPECT().SetColumns(context.Background(), &nsSchema).Return(nil) + cg.EXPECT().TableAdminClient(nil, &nsSchema, "", &WarningBuffer{}).Return(nil, fmt.Errorf("error")) + + s, err := NewProxyStore(scc, cg, rn, cf) + assert.Nil(t, err) + assert.Equal(t, scc, s.columnSetter) + assert.Equal(t, cg, s.clientGetter) + assert.Equal(t, rn, s.notifier) + assert.Equal(t, s.cacheFactory, cf) + assert.Nil(t, s.namespaceCache) + }, + }) + tests = append(tests, testCase{ + description: "NewProxyStore() with client getter TableAdminClient() error returned should return not return and error" + + " and not set namespace cache.", + test: func(t *testing.T) { + scc := NewMockSchemaColumnSetter(gomock.NewController(t)) + cg := NewMockClientGetter(gomock.NewController(t)) + rn := NewMockRelationshipNotifier(gomock.NewController(t)) + cf := NewMockCacheFactory(gomock.NewController(t)) + ri := NewMockResourceInterface(gomock.NewController(t)) + + nsSchema := baseNSSchema + scc.EXPECT().SetColumns(context.Background(), &nsSchema).Return(nil) + cg.EXPECT().TableAdminClient(nil, &nsSchema, "", &WarningBuffer{}).Return(ri, nil) + cf.EXPECT().CacheFor([][]string{{"metadata", "labels[field.cattle.io/projectId]"}}, &tablelistconvert.Client{ResourceInterface: ri}, attributes.GVK(&nsSchema), false).Return(factory.Cache{}, fmt.Errorf("error")) + + s, err := NewProxyStore(scc, cg, rn, cf) + assert.Nil(t, err) + assert.Equal(t, scc, s.columnSetter) + assert.Equal(t, cg, s.clientGetter) + assert.Equal(t, rn, s.notifier) + assert.Equal(t, s.cacheFactory, cf) + assert.Nil(t, s.namespaceCache) + }, + }) + t.Parallel() + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { test.test(t) }) + } +} + +func TestListByPartitions(t *testing.T) { + type testCase struct { + description string + test func(t *testing.T) + } + var tests []testCase + tests = append(tests, testCase{ + description: "client ListByPartitions() with no errors returned should returned no errors. Should pass fields" + + " from schema.", + test: func(t *testing.T) { + nsi := NewMockCache(gomock.NewController(t)) + cg := NewMockClientGetter(gomock.NewController(t)) + cf := NewMockCacheFactory(gomock.NewController(t)) + ri := NewMockResourceInterface(gomock.NewController(t)) + bloi := NewMockByOptionsLister(gomock.NewController(t)) + inf := &informer.Informer{ + ByOptionsLister: bloi, + } + c := factory.Cache{ + ByOptionsLister: inf, + } + s := &Store{ + namespaceCache: nsi, + clientGetter: cg, + cacheFactory: cf, + } + var partitions []partition.Partition + req := &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{}, + }, + } + schema := &types.APISchema{ + Schema: &schemas.Schema{Attributes: map[string]interface{}{ + "columns": []common.ColumnDefinition{ + { + Field: "some.field", + }, + }, + }}, + } + expectedItems := []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + } + listToReturn := &unstructured.UnstructuredList{ + Items: make([]unstructured.Unstructured, len(expectedItems), len(expectedItems)), + } + gvk := schema2.GroupVersionKind{ + Group: "some", + Version: "test", + Kind: "gvk", + } + typeSpecificIndexedFields["some_test_gvk"] = [][]string{{"gvk", "specific", "fields"}} + + attributes.SetGVK(schema, gvk) + // ListByPartitions copies point so we need some original record of items to ensure as asserting listToReturn's + // items is equal to the list returned by ListByParititons doesn't ensure no mutation happened + copy(listToReturn.Items, expectedItems) + opts, err := listprocessor.ParseQuery(req, nil) + assert.Nil(t, err) + cg.EXPECT().TableAdminClient(req, schema, "", &WarningBuffer{}).Return(ri, nil) + // This tests that fields are being extracted from schema columns and the type specific fields map + cf.EXPECT().CacheFor([][]string{{"some", "field"}, {"gvk", "specific", "fields"}}, &tablelistconvert.Client{ResourceInterface: ri}, attributes.GVK(schema), attributes.Namespaced(schema)).Return(c, nil) + bloi.EXPECT().ListByOptions(req.Context(), opts, partitions, req.Namespace).Return(listToReturn, len(listToReturn.Items), "", nil) + list, total, contToken, err := s.ListByPartitions(req, schema, partitions) + assert.Nil(t, err) + assert.Equal(t, expectedItems, list) + assert.Equal(t, len(expectedItems), total) + assert.Equal(t, "", contToken) + }, + }) + tests = append(tests, testCase{ + description: "client ListByPartitions() with ParseQuery error returned should return an error.", + test: func(t *testing.T) { + nsi := NewMockCache(gomock.NewController(t)) + cg := NewMockClientGetter(gomock.NewController(t)) + cf := NewMockCacheFactory(gomock.NewController(t)) + + s := &Store{ + namespaceCache: nsi, + clientGetter: cg, + cacheFactory: cf, + } + var partitions []partition.Partition + req := &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{RawQuery: "projectsornamespaces=somethin"}, + }, + } + schema := &types.APISchema{ + Schema: &schemas.Schema{Attributes: map[string]interface{}{ + "columns": []common.ColumnDefinition{ + { + Field: "some.field", + }, + }, + }}, + } + expectedItems := []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + } + listToReturn := &unstructured.UnstructuredList{ + Items: make([]unstructured.Unstructured, len(expectedItems), len(expectedItems)), + } + gvk := schema2.GroupVersionKind{ + Group: "some", + Version: "test", + Kind: "gvk", + } + typeSpecificIndexedFields["some_test_gvk"] = [][]string{{"gvk", "specific", "fields"}} + + attributes.SetGVK(schema, gvk) + // ListByPartitions copies point so we need some original record of items to ensure as asserting listToReturn's + // items is equal to the list returned by ListByParititons doesn't ensure no mutation happened + copy(listToReturn.Items, expectedItems) + + nsi.EXPECT().ListByOptions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, 0, "", fmt.Errorf("error")).Times(2) + _, err := listprocessor.ParseQuery(req, nsi) + assert.NotNil(t, err) + + _, _, _, err = s.ListByPartitions(req, schema, partitions) + assert.NotNil(t, err) + }, + }) + tests = append(tests, testCase{ + description: "client ListByPartitions() with no errors returned should returned no errors. Should pass fields" + + " from schema.", + test: func(t *testing.T) { + nsi := NewMockCache(gomock.NewController(t)) + cg := NewMockClientGetter(gomock.NewController(t)) + cf := NewMockCacheFactory(gomock.NewController(t)) + + s := &Store{ + namespaceCache: nsi, + clientGetter: cg, + cacheFactory: cf, + } + var partitions []partition.Partition + req := &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{}, + }, + } + schema := &types.APISchema{ + Schema: &schemas.Schema{Attributes: map[string]interface{}{ + "columns": []common.ColumnDefinition{ + { + Field: "some.field", + }, + }, + }}, + } + expectedItems := []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + } + listToReturn := &unstructured.UnstructuredList{ + Items: make([]unstructured.Unstructured, len(expectedItems), len(expectedItems)), + } + gvk := schema2.GroupVersionKind{ + Group: "some", + Version: "test", + Kind: "gvk", + } + typeSpecificIndexedFields["some_test_gvk"] = [][]string{{"gvk", "specific", "fields"}} + + attributes.SetGVK(schema, gvk) + // ListByPartitions copies point so we need some original record of items to ensure as asserting listToReturn's + // items is equal to the list returned by ListByParititons doesn't ensure no mutation happened + copy(listToReturn.Items, expectedItems) + _, err := listprocessor.ParseQuery(req, nil) + assert.Nil(t, err) + cg.EXPECT().TableAdminClient(req, schema, "", &WarningBuffer{}).Return(nil, fmt.Errorf("error")) + + _, _, _, err = s.ListByPartitions(req, schema, partitions) + assert.NotNil(t, err) + }, + }) + tests = append(tests, testCase{ + description: "client ListByPartitions() with CacheFor() error returned should returned an errors. Should pass fields", + test: func(t *testing.T) { + nsi := NewMockCache(gomock.NewController(t)) + cg := NewMockClientGetter(gomock.NewController(t)) + cf := NewMockCacheFactory(gomock.NewController(t)) + ri := NewMockResourceInterface(gomock.NewController(t)) + + s := &Store{ + namespaceCache: nsi, + clientGetter: cg, + cacheFactory: cf, + } + var partitions []partition.Partition + req := &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{}, + }, + } + schema := &types.APISchema{ + Schema: &schemas.Schema{Attributes: map[string]interface{}{ + "columns": []common.ColumnDefinition{ + { + Field: "some.field", + }, + }, + }}, + } + expectedItems := []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + } + listToReturn := &unstructured.UnstructuredList{ + Items: make([]unstructured.Unstructured, len(expectedItems), len(expectedItems)), + } + gvk := schema2.GroupVersionKind{ + Group: "some", + Version: "test", + Kind: "gvk", + } + typeSpecificIndexedFields["some_test_gvk"] = [][]string{{"gvk", "specific", "fields"}} + + attributes.SetGVK(schema, gvk) + // ListByPartitions copies point so we need some original record of items to ensure as asserting listToReturn's + // items is equal to the list returned by ListByParititons doesn't ensure no mutation happened + copy(listToReturn.Items, expectedItems) + _, err := listprocessor.ParseQuery(req, nil) + assert.Nil(t, err) + cg.EXPECT().TableAdminClient(req, schema, "", &WarningBuffer{}).Return(ri, nil) + // This tests that fields are being extracted from schema columns and the type specific fields map + cf.EXPECT().CacheFor([][]string{{"some", "field"}, {"gvk", "specific", "fields"}}, &tablelistconvert.Client{ResourceInterface: ri}, attributes.GVK(schema), attributes.Namespaced(schema)).Return(factory.Cache{}, fmt.Errorf("error")) + + _, _, _, err = s.ListByPartitions(req, schema, partitions) + assert.NotNil(t, err) + }, + }) + tests = append(tests, testCase{ + description: "client ListByPartitions() with ListByOptions() error returned should return an errors. Should pass fields" + + " from schema.", + test: func(t *testing.T) { + nsi := NewMockCache(gomock.NewController(t)) + cg := NewMockClientGetter(gomock.NewController(t)) + cf := NewMockCacheFactory(gomock.NewController(t)) + ri := NewMockResourceInterface(gomock.NewController(t)) + bloi := NewMockByOptionsLister(gomock.NewController(t)) + inf := &informer.Informer{ + ByOptionsLister: bloi, + } + c := factory.Cache{ + ByOptionsLister: inf, + } + s := &Store{ + namespaceCache: nsi, + clientGetter: cg, + cacheFactory: cf, + } + var partitions []partition.Partition + req := &types.APIRequest{ + Request: &http.Request{ + URL: &url.URL{}, + }, + } + schema := &types.APISchema{ + Schema: &schemas.Schema{Attributes: map[string]interface{}{ + "columns": []common.ColumnDefinition{ + { + Field: "some.field", + }, + }, + }}, + } + expectedItems := []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + } + listToReturn := &unstructured.UnstructuredList{ + Items: make([]unstructured.Unstructured, len(expectedItems), len(expectedItems)), + } + gvk := schema2.GroupVersionKind{ + Group: "some", + Version: "test", + Kind: "gvk", + } + typeSpecificIndexedFields["some_test_gvk"] = [][]string{{"gvk", "specific", "fields"}} + + attributes.SetGVK(schema, gvk) + // ListByPartitions copies point so we need some original record of items to ensure as asserting listToReturn's + // items is equal to the list returned by ListByParititons doesn't ensure no mutation happened + copy(listToReturn.Items, expectedItems) + opts, err := listprocessor.ParseQuery(req, nil) + assert.Nil(t, err) + cg.EXPECT().TableAdminClient(req, schema, "", &WarningBuffer{}).Return(ri, nil) + // This tests that fields are being extracted from schema columns and the type specific fields map + cf.EXPECT().CacheFor([][]string{{"some", "field"}, {"gvk", "specific", "fields"}}, &tablelistconvert.Client{ResourceInterface: ri}, attributes.GVK(schema), attributes.Namespaced(schema)).Return(c, nil) + bloi.EXPECT().ListByOptions(req.Context(), opts, partitions, req.Namespace).Return(nil, 0, "", fmt.Errorf("error")) + + _, _, _, err = s.ListByPartitions(req, schema, partitions) + assert.NotNil(t, err) + }, + }) + t.Parallel() + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { test.test(t) }) + } +} + +func TestReset(t *testing.T) { + type testCase struct { + description string + test func(t *testing.T) + } + var tests []testCase + tests = append(tests, testCase{ + description: "client Reset() with no errors returned should returned no errors.", + test: func(t *testing.T) { + nsc := NewMockCache(gomock.NewController(t)) + cg := NewMockClientGetter(gomock.NewController(t)) + cf := NewMockCacheFactory(gomock.NewController(t)) + cs := NewMockSchemaColumnSetter(gomock.NewController(t)) + ri := NewMockResourceInterface(gomock.NewController(t)) + nsc2 := factory.Cache{} + s := &Store{ + namespaceCache: nsc, + clientGetter: cg, + cacheFactory: cf, + columnSetter: cs, + cfInitializer: func() (CacheFactory, error) { return cf, nil }, + } + nsSchema := baseNSSchema + cf.EXPECT().Reset().Return(nil) + cs.EXPECT().SetColumns(gomock.Any(), gomock.Any()).Return(nil) + cg.EXPECT().TableAdminClient(nil, &nsSchema, "", &WarningBuffer{}).Return(ri, nil) + cf.EXPECT().CacheFor([][]string{{"metadata", "labels[field.cattle.io/projectId]"}}, &tablelistconvert.Client{ResourceInterface: ri}, attributes.GVK(&nsSchema), false).Return(nsc2, nil) + err := s.Reset() + assert.Nil(t, err) + assert.Equal(t, nsc2, s.namespaceCache) + }, + }) + tests = append(tests, testCase{ + description: "client Reset() with cache factory Reset() error returned, should return an error.", + test: func(t *testing.T) { + nsi := NewMockCache(gomock.NewController(t)) + cg := NewMockClientGetter(gomock.NewController(t)) + cf := NewMockCacheFactory(gomock.NewController(t)) + cs := NewMockSchemaColumnSetter(gomock.NewController(t)) + + s := &Store{ + namespaceCache: nsi, + clientGetter: cg, + cacheFactory: cf, + columnSetter: cs, + cfInitializer: func() (CacheFactory, error) { return cf, nil }, + } + + cf.EXPECT().Reset().Return(fmt.Errorf("error")) + err := s.Reset() + assert.NotNil(t, err) + }, + }) + tests = append(tests, testCase{ + description: "client Reset() with column setter error returned, should return an error.", + test: func(t *testing.T) { + nsi := NewMockCache(gomock.NewController(t)) + cg := NewMockClientGetter(gomock.NewController(t)) + cf := NewMockCacheFactory(gomock.NewController(t)) + cs := NewMockSchemaColumnSetter(gomock.NewController(t)) + + s := &Store{ + namespaceCache: nsi, + clientGetter: cg, + cacheFactory: cf, + columnSetter: cs, + cfInitializer: func() (CacheFactory, error) { return cf, nil }, + } + + cf.EXPECT().Reset().Return(nil) + cs.EXPECT().SetColumns(gomock.Any(), gomock.Any()).Return(fmt.Errorf("error")) + err := s.Reset() + assert.NotNil(t, err) + }, + }) + tests = append(tests, testCase{ + description: "client Reset() with column getter TableAdminClient() error returned, should return an error.", + test: func(t *testing.T) { + nsi := NewMockCache(gomock.NewController(t)) + cg := NewMockClientGetter(gomock.NewController(t)) + cf := NewMockCacheFactory(gomock.NewController(t)) + cs := NewMockSchemaColumnSetter(gomock.NewController(t)) + + s := &Store{ + namespaceCache: nsi, + clientGetter: cg, + cacheFactory: cf, + columnSetter: cs, + cfInitializer: func() (CacheFactory, error) { return cf, nil }, + } + nsSchema := baseNSSchema + + cf.EXPECT().Reset().Return(nil) + cs.EXPECT().SetColumns(gomock.Any(), gomock.Any()).Return(nil) + cg.EXPECT().TableAdminClient(nil, &nsSchema, "", &WarningBuffer{}).Return(nil, fmt.Errorf("error")) + err := s.Reset() + assert.NotNil(t, err) + }, + }) + tests = append(tests, testCase{ + description: "client Reset() with cache factory CacheFor() error returned, should return an error.", + test: func(t *testing.T) { + nsc := NewMockCache(gomock.NewController(t)) + cg := NewMockClientGetter(gomock.NewController(t)) + cf := NewMockCacheFactory(gomock.NewController(t)) + cs := NewMockSchemaColumnSetter(gomock.NewController(t)) + ri := NewMockResourceInterface(gomock.NewController(t)) + + s := &Store{ + namespaceCache: nsc, + clientGetter: cg, + cacheFactory: cf, + columnSetter: cs, + cfInitializer: func() (CacheFactory, error) { return cf, nil }, + } + nsSchema := baseNSSchema + + cf.EXPECT().Reset().Return(nil) + cs.EXPECT().SetColumns(gomock.Any(), gomock.Any()).Return(nil) + cg.EXPECT().TableAdminClient(nil, &nsSchema, "", &WarningBuffer{}).Return(ri, nil) + cf.EXPECT().CacheFor([][]string{{"metadata", "labels[field.cattle.io/projectId]"}}, &tablelistconvert.Client{ResourceInterface: ri}, attributes.GVK(&nsSchema), false).Return(factory.Cache{}, fmt.Errorf("error")) + err := s.Reset() + assert.NotNil(t, err) + }, + }) + t.Parallel() + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { test.test(t) }) + } +} + +func TestWatchNamesErrReceive(t *testing.T) { + testClientFactory, err := client.NewFactory(&rest.Config{}, false) + assert.Nil(t, err) + + fakeClient := fake.NewSimpleDynamicClient(runtime.NewScheme()) + c = watch.NewFakeWithChanSize(5, true) + defer c.Stop() + errMsgsToSend := []string{"err1", "err2", "err3"} + c.Add(&v1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "testsecret1"}}) + for index := range errMsgsToSend { + c.Error(&metav1.Status{ + Message: errMsgsToSend[index], + }) + } + c.Add(&v1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "testsecret2"}}) + fakeClient.PrependWatchReactor("*", func(action clientgotesting.Action) (handled bool, ret watch.Interface, err error) { + return true, c, nil + }) + testStore := Store{ + clientGetter: &testFactory{Factory: testClientFactory, + fakeClient: fakeClient, + }, + } + apiSchema := &types.APISchema{Schema: &schemas.Schema{Attributes: map[string]interface{}{"table": "something"}}} + wc, err := testStore.WatchNames(&types.APIRequest{Namespace: "", Schema: apiSchema, Request: &http.Request{}}, apiSchema, types.WatchRequest{}, sets.New[string]("testsecret1", "testsecret2")) + assert.Nil(t, err) + + eg := errgroup.Group{} + eg.Go(func() error { return receiveUntil(wc, 5*time.Second) }) + + err = eg.Wait() + assert.Nil(t, err) + + assert.Equal(t, 0, len(c.ResultChan()), "Expected all secrets to have been received") +} + +func (t *testFactory) TableAdminClientForWatch(ctx *types.APIRequest, schema *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) { + return t.fakeClient.Resource(schema2.GroupVersionResource{}), nil +} + +func receiveUntil(wc chan watch.Event, d time.Duration) error { + timer := time.NewTicker(d) + defer timer.Stop() + secretNames := []string{"testsecret1", "testsecret2"} + errMsgs := []string{"err1", "err2", "err3"} + for { + select { + case event, ok := <-wc: + if !ok { + return errors.New("watch chan should not have been closed") + } + + if event.Type == watch.Error { + status, ok := event.Object.(*metav1.Status) + if !ok { + continue + } + if strings.HasSuffix(status.Message, errMsgs[0]) { + errMsgs = errMsgs[1:] + } + } + secret, ok := event.Object.(*v1.Secret) + if !ok { + continue + } + if secret.Name == secretNames[0] { + secretNames = secretNames[1:] + } + if len(secretNames) == 0 && len(errMsgs) == 0 { + return nil + } + continue + case <-timer.C: + return errors.New("timed out waiting to receiving objects from chan") + } + } +} diff --git a/pkg/stores/sqlproxy/sql_informer_mocks_test.go b/pkg/stores/sqlproxy/sql_informer_mocks_test.go new file mode 100644 index 00000000..7e086dbd --- /dev/null +++ b/pkg/stores/sqlproxy/sql_informer_mocks_test.go @@ -0,0 +1,55 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/rancher/lasso/pkg/cache/sql/informer (interfaces: ByOptionsLister) + +// Package sqlproxy is a generated GoMock package. +package sqlproxy + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + informer "github.com/rancher/lasso/pkg/cache/sql/informer" + partition "github.com/rancher/lasso/pkg/cache/sql/partition" + unstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// MockByOptionsLister is a mock of ByOptionsLister interface. +type MockByOptionsLister struct { + ctrl *gomock.Controller + recorder *MockByOptionsListerMockRecorder +} + +// MockByOptionsListerMockRecorder is the mock recorder for MockByOptionsLister. +type MockByOptionsListerMockRecorder struct { + mock *MockByOptionsLister +} + +// NewMockByOptionsLister creates a new mock instance. +func NewMockByOptionsLister(ctrl *gomock.Controller) *MockByOptionsLister { + mock := &MockByOptionsLister{ctrl: ctrl} + mock.recorder = &MockByOptionsListerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockByOptionsLister) EXPECT() *MockByOptionsListerMockRecorder { + return m.recorder +} + +// ListByOptions mocks base method. +func (m *MockByOptionsLister) ListByOptions(arg0 context.Context, arg1 informer.ListOptions, arg2 []partition.Partition, arg3 string) (*unstructured.UnstructuredList, int, string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListByOptions", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*unstructured.UnstructuredList) + ret1, _ := ret[1].(int) + ret2, _ := ret[2].(string) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 +} + +// ListByOptions indicates an expected call of ListByOptions. +func (mr *MockByOptionsListerMockRecorder) ListByOptions(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByOptions", reflect.TypeOf((*MockByOptionsLister)(nil).ListByOptions), arg0, arg1, arg2, arg3) +} diff --git a/pkg/stores/sqlproxy/tablelistconvert/client.go b/pkg/stores/sqlproxy/tablelistconvert/client.go new file mode 100644 index 00000000..11f1b6ef --- /dev/null +++ b/pkg/stores/sqlproxy/tablelistconvert/client.go @@ -0,0 +1,131 @@ +/* +Package tablelistconvert provides a client that will use a table client but convert *UnstructuredList and *Unstructured objects +returned by ByID and List to resemble those returned by non-table clients while preserving some table-related data. +*/ +package tablelistconvert + +import ( + "context" + + "github.com/rancher/wrangler/v3/pkg/data" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8sWatch "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" +) + +type Client struct { + dynamic.ResourceInterface +} + +var _ dynamic.ResourceInterface = (*Client)(nil) + +type tableConvertWatch struct { + done chan struct{} + events chan k8sWatch.Event + k8sWatch.Interface +} + +// List will return an *UnstructuredList that contains Items instead of just using the Object field to store a table as +// Table Clients do. The items will preserve values for columns in the form of metadata.fields. +func (c *Client) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { + list, err := c.ResourceInterface.List(ctx, opts) + if err != nil { + return nil, err + } + tableToList(list) + return list, nil +} + +func (c *Client) Watch(ctx context.Context, opts metav1.ListOptions) (k8sWatch.Interface, error) { + w, err := c.ResourceInterface.Watch(ctx, opts) + if err != nil { + return nil, err + } + events := make(chan k8sWatch.Event) + done := make(chan struct{}) + eventWatch := &tableConvertWatch{done: done, events: events, Interface: w} + eventWatch.feed() + return eventWatch, nil +} + +func (w *tableConvertWatch) feed() { + tableEvents := w.Interface.ResultChan() + go func() { + for { + select { + case e, ok := <-tableEvents: + if !ok { + close(w.events) + return + } + if unstr, ok := e.Object.(*unstructured.Unstructured); ok { + rowToObject(unstr) + w.events <- e + } + case <-w.done: + close(w.events) + return + } + } + }() +} + +func (w *tableConvertWatch) ResultChan() <-chan k8sWatch.Event { + return w.events +} + +func (w *tableConvertWatch) Stop() { + close(w.done) + w.Interface.Stop() +} + +func rowToObject(obj *unstructured.Unstructured) { + if obj == nil { + return + } + if obj.Object["kind"] != "Table" || + (obj.Object["apiVersion"] != "meta.k8s.io/v1" && + obj.Object["apiVersion"] != "meta.k8s.io/v1beta1") { + return + } + + items := tableToObjects(obj.Object) + if len(items) == 1 { + obj.Object = items[0].Object + } +} + +func tableToList(obj *unstructured.UnstructuredList) { + if obj.Object["kind"] != "Table" || + (obj.Object["apiVersion"] != "meta.k8s.io/v1" && + obj.Object["apiVersion"] != "meta.k8s.io/v1beta1") { + return + } + + obj.Items = tableToObjects(obj.Object) +} + +func tableToObjects(obj map[string]interface{}) []unstructured.Unstructured { + var result []unstructured.Unstructured + + rows, _ := obj["rows"].([]interface{}) + for _, row := range rows { + m, ok := row.(map[string]interface{}) + if !ok { + continue + } + cells := m["cells"] + object, ok := m["object"].(map[string]interface{}) + if !ok { + continue + } + + data.PutValue(object, cells, "metadata", "fields") + result = append(result, unstructured.Unstructured{ + Object: object, + }) + } + + return result +} diff --git a/pkg/stores/sqlproxy/tablelistconvert/client_test.go b/pkg/stores/sqlproxy/tablelistconvert/client_test.go new file mode 100644 index 00000000..fcea2353 --- /dev/null +++ b/pkg/stores/sqlproxy/tablelistconvert/client_test.go @@ -0,0 +1,270 @@ +package tablelistconvert + +import ( + "context" + "fmt" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + watch2 "k8s.io/apimachinery/pkg/watch" + "testing" + "time" +) + +//go:generate mockgen --build_flags=--mod=mod -package tablelistconvert -destination ./dynamic_mocks_test.go k8s.io/client-go/dynamic ResourceInterface +//go:generate mockgen --build_flags=--mod=mod -package tablelistconvert -destination ./watch_mocks_test.go k8s.io/apimachinery/pkg/watch Interface + +func TestWatch(t *testing.T) { + type testCase struct { + description string + test func(t *testing.T) + } + + var tests []testCase + tests = append(tests, testCase{ + description: "client Watch() with no errors returned should returned no errors. Objects passed to underlying channel should" + + " be sent with expected metadata.fields", + test: func(t *testing.T) { + ri := NewMockResourceInterface(gomock.NewController(t)) + watch := NewMockInterface(gomock.NewController(t)) + testEvents := make(chan watch2.Event) + opts := metav1.ListOptions{} + ri.EXPECT().Watch(context.TODO(), opts).Return(watch, nil) + initialEvent := watch2.Event{ + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "Table", + "apiVersion": "meta.k8s.io/v1", + "rows": []interface{}{ + map[string]interface{}{ + "cells": []interface{}{"cell1", "cell2"}, + "object": map[string]interface{}{}, + }, + }, + }, + }, + } + expectedEvent := watch2.Event{ + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "fields": []interface{}{"cell1", "cell2"}, + }, + }, + }, + } + go func() { + time.Sleep(1 * time.Second) + testEvents <- initialEvent + }() + watch.EXPECT().ResultChan().Return(testEvents) + client := &Client{ResourceInterface: ri} + receivedWatch, err := client.Watch(context.TODO(), opts) + assert.Nil(t, err) + receivedEvent, ok := <-receivedWatch.ResultChan() + assert.True(t, ok) + assert.Equal(t, expectedEvent, receivedEvent) + }, + }) + tests = append(tests, testCase{ + description: "client Watch() with no errors returned should returned no errors. Objects passed to underlying channel that are not of type \"table\"" + + " should not be sent with metadata.fields", + test: func(t *testing.T) { + ri := NewMockResourceInterface(gomock.NewController(t)) + watch := NewMockInterface(gomock.NewController(t)) + testEvents := make(chan watch2.Event) + opts := metav1.ListOptions{} + ri.EXPECT().Watch(context.TODO(), opts).Return(watch, nil) + initialEvent := watch2.Event{ + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "NotTable", + "apiVersion": "meta.k8s.io/v1", + "rows": []interface{}{ + map[string]interface{}{ + "cells": []interface{}{"cell1", "cell2"}, + "object": map[string]interface{}{}, + }, + }, + }, + }, + } + expectedEvent := watch2.Event{ + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "NotTable", + "apiVersion": "meta.k8s.io/v1", + "rows": []interface{}{ + map[string]interface{}{ + "cells": []interface{}{"cell1", "cell2"}, + "object": map[string]interface{}{}, + }, + }, + }, + }, + } + go func() { + time.Sleep(1 * time.Second) + testEvents <- initialEvent + }() + watch.EXPECT().ResultChan().Return(testEvents) + client := &Client{ResourceInterface: ri} + receivedWatch, err := client.Watch(context.TODO(), opts) + assert.Nil(t, err) + receivedEvent, ok := <-receivedWatch.ResultChan() + assert.True(t, ok) + assert.Equal(t, expectedEvent, receivedEvent) + }, + }) + tests = append(tests, testCase{ + description: "client Watch() with no errors returned should returned no errors. Nil objects passed to underlying" + + " channel should be sent as nil", + test: func(t *testing.T) { + ri := NewMockResourceInterface(gomock.NewController(t)) + watch := NewMockInterface(gomock.NewController(t)) + testEvents := make(chan watch2.Event) + opts := metav1.ListOptions{} + ri.EXPECT().Watch(context.TODO(), opts).Return(watch, nil) + initialEvent := watch2.Event{ + Object: &unstructured.Unstructured{ + nil, + }, + } + expectedEvent := watch2.Event{ + Object: &unstructured.Unstructured{ + Object: nil, + }, + } + go func() { + time.Sleep(1 * time.Second) + testEvents <- initialEvent + }() + watch.EXPECT().ResultChan().Return(testEvents) + client := &Client{ResourceInterface: ri} + receivedWatch, err := client.Watch(context.TODO(), opts) + assert.Nil(t, err) + receivedEvent, ok := <-receivedWatch.ResultChan() + assert.True(t, ok) + assert.Equal(t, expectedEvent, receivedEvent) + }, + }) + tests = append(tests, testCase{ + description: "client Watch() with error returned from resource client should returned an errors", + test: func(t *testing.T) { + ri := NewMockResourceInterface(gomock.NewController(t)) + opts := metav1.ListOptions{} + ri.EXPECT().Watch(context.TODO(), opts).Return(nil, fmt.Errorf("error")) + client := &Client{ResourceInterface: ri} + _, err := client.Watch(context.TODO(), opts) + assert.NotNil(t, err) + }, + }) + t.Parallel() + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { test.test(t) }) + } +} + +func TestList(t *testing.T) { + type testCase struct { + description string + test func(t *testing.T) + } + + var tests []testCase + tests = append(tests, testCase{ + description: "client List() with no errors returned should returned no errors. Received list should be mutated" + + "to contain rows.objects in Objects field and metadata.fields should be added to both.", + test: func(t *testing.T) { + ri := NewMockResourceInterface(gomock.NewController(t)) + opts := metav1.ListOptions{} + initialList := &unstructured.UnstructuredList{ + Object: map[string]interface{}{ + "kind": "Table", + "apiVersion": "meta.k8s.io/v1", + "rows": []interface{}{ + map[string]interface{}{ + "cells": []interface{}{"cell1", "cell2"}, + "object": map[string]interface{}{}, + }, + }, + }, + Items: []unstructured.Unstructured{ + {}, + }, + } + expectedList := &unstructured.UnstructuredList{ + Object: map[string]interface{}{ + "kind": "Table", + "apiVersion": "meta.k8s.io/v1", + "rows": []interface{}{ + map[string]interface{}{ + "cells": []interface{}{"cell1", "cell2"}, + "object": map[string]interface{}{ + "metadata": map[string]interface{}{ + "fields": []interface{}{"cell1", "cell2"}, + }}, + }, + }, + }, + Items: []unstructured.Unstructured{ + {Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "fields": []interface{}{"cell1", "cell2"}, + }, + }}, + }, + } + ri.EXPECT().List(context.TODO(), opts).Return(initialList, nil) + client := &Client{ResourceInterface: ri} + receivedList, err := client.List(context.TODO(), opts) + assert.Nil(t, err) + assert.Equal(t, expectedList, receivedList) + }, + }) + tests = append(tests, testCase{ + description: "client List() with no errors returned should returned no errors. Received list should be not mutated" + + "if kind is not \"Table\".", + test: func(t *testing.T) { + ri := NewMockResourceInterface(gomock.NewController(t)) + opts := metav1.ListOptions{} + initialList := &unstructured.UnstructuredList{ + Object: map[string]interface{}{ + "kind": "NotTable", + "apiVersion": "meta.k8s.io/v1", + "rows": []interface{}{ + map[string]interface{}{ + "cells": []interface{}{"cell1", "cell2"}, + "object": map[string]interface{}{}, + }, + }, + }, + Items: []unstructured.Unstructured{ + {}, + }, + } + ri.EXPECT().List(context.TODO(), opts).Return(initialList, nil) + client := &Client{ResourceInterface: ri} + receivedList, err := client.List(context.TODO(), opts) + assert.Nil(t, err) + assert.Equal(t, initialList, receivedList) + }, + }) + tests = append(tests, testCase{ + description: "client List() with errors returned from Resource Interface should returned an error" + + "if kind is not \"Table\".", + test: func(t *testing.T) { + ri := NewMockResourceInterface(gomock.NewController(t)) + opts := metav1.ListOptions{} + ri.EXPECT().List(context.TODO(), opts).Return(nil, fmt.Errorf("error")) + client := &Client{ResourceInterface: ri} + _, err := client.List(context.TODO(), opts) + assert.NotNil(t, err) + }, + }) + t.Parallel() + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { test.test(t) }) + } +} diff --git a/pkg/stores/sqlproxy/tablelistconvert/dynamic_mocks_test.go b/pkg/stores/sqlproxy/tablelistconvert/dynamic_mocks_test.go new file mode 100644 index 00000000..6b2e7fa2 --- /dev/null +++ b/pkg/stores/sqlproxy/tablelistconvert/dynamic_mocks_test.go @@ -0,0 +1,232 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: k8s.io/client-go/dynamic (interfaces: ResourceInterface) + +// Package tablelistconvert is a generated GoMock package. +package tablelistconvert + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + unstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" +) + +// MockResourceInterface is a mock of ResourceInterface interface. +type MockResourceInterface struct { + ctrl *gomock.Controller + recorder *MockResourceInterfaceMockRecorder +} + +// MockResourceInterfaceMockRecorder is the mock recorder for MockResourceInterface. +type MockResourceInterfaceMockRecorder struct { + mock *MockResourceInterface +} + +// NewMockResourceInterface creates a new mock instance. +func NewMockResourceInterface(ctrl *gomock.Controller) *MockResourceInterface { + mock := &MockResourceInterface{ctrl: ctrl} + mock.recorder = &MockResourceInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockResourceInterface) EXPECT() *MockResourceInterfaceMockRecorder { + return m.recorder +} + +// Apply mocks base method. +func (m *MockResourceInterface) Apply(arg0 context.Context, arg1 string, arg2 *unstructured.Unstructured, arg3 v1.ApplyOptions, arg4 ...string) (*unstructured.Unstructured, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2, arg3} + for _, a := range arg4 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Apply", varargs...) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Apply indicates an expected call of Apply. +func (mr *MockResourceInterfaceMockRecorder) Apply(arg0, arg1, arg2, arg3 interface{}, arg4 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2, arg3}, arg4...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Apply", reflect.TypeOf((*MockResourceInterface)(nil).Apply), varargs...) +} + +// ApplyStatus mocks base method. +func (m *MockResourceInterface) ApplyStatus(arg0 context.Context, arg1 string, arg2 *unstructured.Unstructured, arg3 v1.ApplyOptions) (*unstructured.Unstructured, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ApplyStatus", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ApplyStatus indicates an expected call of ApplyStatus. +func (mr *MockResourceInterfaceMockRecorder) ApplyStatus(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyStatus", reflect.TypeOf((*MockResourceInterface)(nil).ApplyStatus), arg0, arg1, arg2, arg3) +} + +// Create mocks base method. +func (m *MockResourceInterface) Create(arg0 context.Context, arg1 *unstructured.Unstructured, arg2 v1.CreateOptions, arg3 ...string) (*unstructured.Unstructured, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2} + for _, a := range arg3 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Create", varargs...) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockResourceInterfaceMockRecorder) Create(arg0, arg1, arg2 interface{}, arg3 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2}, arg3...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockResourceInterface)(nil).Create), varargs...) +} + +// Delete mocks base method. +func (m *MockResourceInterface) Delete(arg0 context.Context, arg1 string, arg2 v1.DeleteOptions, arg3 ...string) error { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2} + for _, a := range arg3 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockResourceInterfaceMockRecorder) Delete(arg0, arg1, arg2 interface{}, arg3 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2}, arg3...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockResourceInterface)(nil).Delete), varargs...) +} + +// DeleteCollection mocks base method. +func (m *MockResourceInterface) DeleteCollection(arg0 context.Context, arg1 v1.DeleteOptions, arg2 v1.ListOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteCollection", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteCollection indicates an expected call of DeleteCollection. +func (mr *MockResourceInterfaceMockRecorder) DeleteCollection(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCollection", reflect.TypeOf((*MockResourceInterface)(nil).DeleteCollection), arg0, arg1, arg2) +} + +// Get mocks base method. +func (m *MockResourceInterface) Get(arg0 context.Context, arg1 string, arg2 v1.GetOptions, arg3 ...string) (*unstructured.Unstructured, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2} + for _, a := range arg3 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Get", varargs...) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockResourceInterfaceMockRecorder) Get(arg0, arg1, arg2 interface{}, arg3 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2}, arg3...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockResourceInterface)(nil).Get), varargs...) +} + +// List mocks base method. +func (m *MockResourceInterface) List(arg0 context.Context, arg1 v1.ListOptions) (*unstructured.UnstructuredList, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", arg0, arg1) + ret0, _ := ret[0].(*unstructured.UnstructuredList) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockResourceInterfaceMockRecorder) List(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockResourceInterface)(nil).List), arg0, arg1) +} + +// Patch mocks base method. +func (m *MockResourceInterface) Patch(arg0 context.Context, arg1 string, arg2 types.PatchType, arg3 []byte, arg4 v1.PatchOptions, arg5 ...string) (*unstructured.Unstructured, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2, arg3, arg4} + for _, a := range arg5 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Patch", varargs...) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Patch indicates an expected call of Patch. +func (mr *MockResourceInterfaceMockRecorder) Patch(arg0, arg1, arg2, arg3, arg4 interface{}, arg5 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2, arg3, arg4}, arg5...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockResourceInterface)(nil).Patch), varargs...) +} + +// Update mocks base method. +func (m *MockResourceInterface) Update(arg0 context.Context, arg1 *unstructured.Unstructured, arg2 v1.UpdateOptions, arg3 ...string) (*unstructured.Unstructured, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2} + for _, a := range arg3 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Update", varargs...) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockResourceInterfaceMockRecorder) Update(arg0, arg1, arg2 interface{}, arg3 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2}, arg3...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockResourceInterface)(nil).Update), varargs...) +} + +// UpdateStatus mocks base method. +func (m *MockResourceInterface) UpdateStatus(arg0 context.Context, arg1 *unstructured.Unstructured, arg2 v1.UpdateOptions) (*unstructured.Unstructured, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateStatus", arg0, arg1, arg2) + ret0, _ := ret[0].(*unstructured.Unstructured) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateStatus indicates an expected call of UpdateStatus. +func (mr *MockResourceInterfaceMockRecorder) UpdateStatus(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateStatus", reflect.TypeOf((*MockResourceInterface)(nil).UpdateStatus), arg0, arg1, arg2) +} + +// Watch mocks base method. +func (m *MockResourceInterface) Watch(arg0 context.Context, arg1 v1.ListOptions) (watch.Interface, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Watch", arg0, arg1) + ret0, _ := ret[0].(watch.Interface) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Watch indicates an expected call of Watch. +func (mr *MockResourceInterfaceMockRecorder) Watch(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Watch", reflect.TypeOf((*MockResourceInterface)(nil).Watch), arg0, arg1) +} diff --git a/pkg/stores/sqlproxy/tablelistconvert/watch_mocks_test.go b/pkg/stores/sqlproxy/tablelistconvert/watch_mocks_test.go new file mode 100644 index 00000000..623c614f --- /dev/null +++ b/pkg/stores/sqlproxy/tablelistconvert/watch_mocks_test.go @@ -0,0 +1,61 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: k8s.io/apimachinery/pkg/watch (interfaces: Interface) + +// Package tablelistconvert is a generated GoMock package. +package tablelistconvert + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + watch "k8s.io/apimachinery/pkg/watch" +) + +// MockInterface is a mock of Interface interface. +type MockInterface struct { + ctrl *gomock.Controller + recorder *MockInterfaceMockRecorder +} + +// MockInterfaceMockRecorder is the mock recorder for MockInterface. +type MockInterfaceMockRecorder struct { + mock *MockInterface +} + +// NewMockInterface creates a new mock instance. +func NewMockInterface(ctrl *gomock.Controller) *MockInterface { + mock := &MockInterface{ctrl: ctrl} + mock.recorder = &MockInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockInterface) EXPECT() *MockInterfaceMockRecorder { + return m.recorder +} + +// ResultChan mocks base method. +func (m *MockInterface) ResultChan() <-chan watch.Event { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResultChan") + ret0, _ := ret[0].(<-chan watch.Event) + return ret0 +} + +// ResultChan indicates an expected call of ResultChan. +func (mr *MockInterfaceMockRecorder) ResultChan() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResultChan", reflect.TypeOf((*MockInterface)(nil).ResultChan)) +} + +// Stop mocks base method. +func (m *MockInterface) Stop() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Stop") +} + +// Stop indicates an expected call of Stop. +func (mr *MockInterfaceMockRecorder) Stop() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockInterface)(nil).Stop)) +} diff --git a/pkg/stores/switchstore/store.go b/pkg/stores/switchstore/store.go deleted file mode 100644 index 95dfb31a..00000000 --- a/pkg/stores/switchstore/store.go +++ /dev/null @@ -1,59 +0,0 @@ -package switchstore - -import ( - "github.com/rancher/apiserver/pkg/types" -) - -type StorePicker func(apiOp *types.APIRequest, schema *types.APISchema, verb, id string) (types.Store, error) - -type Store struct { - Picker StorePicker -} - -func (e *Store) Delete(apiOp *types.APIRequest, schema *types.APISchema, id string) (types.APIObject, error) { - s, err := e.Picker(apiOp, schema, "delete", id) - if err != nil { - return types.APIObject{}, err - } - return s.Delete(apiOp, schema, id) -} - -func (e *Store) ByID(apiOp *types.APIRequest, schema *types.APISchema, id string) (types.APIObject, error) { - s, err := e.Picker(apiOp, schema, "get", id) - if err != nil { - return types.APIObject{}, err - } - return s.ByID(apiOp, schema, id) -} - -func (e *Store) List(apiOp *types.APIRequest, schema *types.APISchema) (types.APIObjectList, error) { - s, err := e.Picker(apiOp, schema, "list", "") - if err != nil { - return types.APIObjectList{}, err - } - return s.List(apiOp, schema) -} - -func (e *Store) Create(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject) (types.APIObject, error) { - s, err := e.Picker(apiOp, schema, "create", "") - if err != nil { - return types.APIObject{}, err - } - return s.Create(apiOp, schema, data) -} - -func (e *Store) Update(apiOp *types.APIRequest, schema *types.APISchema, data types.APIObject, id string) (types.APIObject, error) { - s, err := e.Picker(apiOp, schema, "update", id) - if err != nil { - return types.APIObject{}, err - } - return s.Update(apiOp, schema, data, id) -} - -func (e *Store) Watch(apiOp *types.APIRequest, schema *types.APISchema, wr types.WatchRequest) (chan types.APIEvent, error) { - s, err := e.Picker(apiOp, schema, "watch", "") - if err != nil { - return nil, err - } - return s.Watch(apiOp, schema, wr) -} diff --git a/pkg/summarycache/summarycache.go b/pkg/summarycache/summarycache.go index 326bacdf..90a1055e 100644 --- a/pkg/summarycache/summarycache.go +++ b/pkg/summarycache/summarycache.go @@ -10,8 +10,8 @@ import ( "github.com/rancher/steve/pkg/clustercache" "github.com/rancher/steve/pkg/schema" "github.com/rancher/steve/pkg/schema/converter" - "github.com/rancher/wrangler/pkg/slice" - "github.com/rancher/wrangler/pkg/summary" + "github.com/rancher/wrangler/v3/pkg/slice" + "github.com/rancher/wrangler/v3/pkg/summary" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" diff --git a/pkg/ui/routes.go b/pkg/ui/routes.go index aa9795a9..22269b1d 100644 --- a/pkg/ui/routes.go +++ b/pkg/ui/routes.go @@ -24,7 +24,7 @@ type spaHandler struct { // is suitable behavior for serving an SPA (single page application). func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // get the absolute path to prevent directory traversal - path, err := filepath.Abs(r.URL.Path) + _, err := filepath.Abs(r.URL.Path) if err != nil { // if we failed to get the absolute path respond with a 400 bad request // and stop @@ -33,7 +33,7 @@ func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // prepend the path with the path to the static directory - path = filepath.Join(h.staticPath, r.URL.Path) + path := filepath.Join(h.staticPath, r.URL.Path) // check whether a file exists at the given path _, err = os.Stat(path) diff --git a/scripts/build-bin.sh b/scripts/build-bin.sh new file mode 100644 index 00000000..52994fea --- /dev/null +++ b/scripts/build-bin.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +CGO_ENABLED=0 go build -ldflags "-extldflags -static -s" -o ./bin/steve \ No newline at end of file diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100644 index 00000000..f9143f72 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +go test ./... \ No newline at end of file diff --git a/scripts/validate.sh b/scripts/validate.sh new file mode 100644 index 00000000..07be14e1 --- /dev/null +++ b/scripts/validate.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e +go generate ./... +golangci-lint run +go mod tidy +go mod verify +unclean=$(git status --porcelain --untracked-files=no) +if [ -n "$unclean" ]; then + echo "Encountered dirty repo!" + echo "$unclean" + exit 1 +fi