mirror of
https://github.com/rancher/steve.git
synced 2025-08-08 09:47:17 +00:00
Compare commits
16 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
458a12d2d7 | ||
|
6b5543e762 | ||
|
b88abd9aa0 | ||
|
a2116cfa52 | ||
|
c4ebbe629f | ||
|
ddd2e373b7 | ||
|
6e30359c65 | ||
|
7dafe0c662 | ||
|
dd27bd0c8d | ||
|
aacb5b82de | ||
|
79304d93b4 | ||
|
096afba4f7 | ||
|
61be17faa3 | ||
|
1464a203c3 | ||
|
643002b00f | ||
|
244a1993e0 |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
- name: Install golangci-lint
|
||||
uses: golangci/golangci-lint-action@a4f60bb28d35aeee14e6880718e0c85ff1882e64 # v6.0.1
|
||||
with:
|
||||
version: v1.59.0
|
||||
version: v1.63.4
|
||||
- name: Build
|
||||
run: make build-bin
|
||||
- name: Test
|
||||
|
21
.github/workflows/release.yaml
vendored
Normal file
21
.github/workflows/release.yaml
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name : Checkout repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Create release on Github
|
||||
run: |
|
||||
gh --repo "${{ github.repository }}" release create ${{ github.ref_name }} --verify-tag --generate-notes
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
@ -1,5 +1,5 @@
|
||||
# syntax = docker/dockerfile:experimental
|
||||
FROM registry.suse.com/bci/golang:1.22 as build
|
||||
FROM registry.suse.com/bci/golang:1.23 as build
|
||||
COPY go.mod go.sum main.go /src/
|
||||
COPY pkg /src/pkg/
|
||||
#RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
|
11
README.md
11
README.md
@ -124,6 +124,17 @@ item is included in the list.
|
||||
/v1/{type}?filter=spec.containers.image=alpine
|
||||
```
|
||||
|
||||
**If SQLite caching is enabled** (`server.Options.SQLCache=true`),
|
||||
filtering is only supported for a subset of attributes:
|
||||
- `id`, `metadata.name`, `metadata.namespace`, `metadata.state.name`, and `metadata.timestamp` for any resource kind
|
||||
- a short list of hardcoded attributes for a selection of specific types listed
|
||||
in [typeSpecificIndexFields](https://github.com/rancher/steve/blob/main/pkg/stores/sqlproxy/proxy_store.go#L52-L58)
|
||||
- the special string `metadata.fields[N]`, with N starting at 0, for all columns
|
||||
displayed by `kubectl get $TYPE`. For example `secrets` have `"metadata.fields[0]"`,
|
||||
`"metadata.fields[1]"` , `"metadata.fields[2]"`, and `"metadata.fields[3]"` respectively
|
||||
corresponding to `"name"`, `"type"`, `"data"`, and `"age"`. For CRDs, these come from
|
||||
[Additional printer columns](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#additional-printer-columns)
|
||||
|
||||
#### `projectsornamespaces`
|
||||
|
||||
Resources can also be filtered by the Rancher projects their namespaces belong
|
||||
|
99
go.mod
99
go.mod
@ -1,8 +1,47 @@
|
||||
module github.com/rancher/steve
|
||||
|
||||
go 1.22.0
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.22.2
|
||||
toolchain go1.23.4
|
||||
|
||||
// pin Kubernetes dependencies to v0.30.1, which is the version
|
||||
// Rancher 2.9 is compiled against as newer lasso versions
|
||||
// pull in newer Kubernetes dependencies
|
||||
replace (
|
||||
github.com/google/cel-go => github.com/google/cel-go v0.17.8
|
||||
github.com/prometheus/client_golang => github.com/prometheus/client_golang v1.16.0
|
||||
github.com/prometheus/client_model => github.com/prometheus/client_model v0.4.0
|
||||
github.com/prometheus/common => github.com/prometheus/common v0.44.0
|
||||
k8s.io/api => k8s.io/api v0.30.1
|
||||
k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.30.1
|
||||
k8s.io/apimachinery => k8s.io/apimachinery v0.30.1
|
||||
k8s.io/apiserver => k8s.io/apiserver v0.30.1
|
||||
k8s.io/cli-runtime => k8s.io/cli-runtime v0.30.1
|
||||
k8s.io/client-go => k8s.io/client-go v0.30.1
|
||||
k8s.io/cloud-provider => k8s.io/cloud-provider v0.30.1
|
||||
k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.30.1
|
||||
k8s.io/code-generator => k8s.io/code-generator v0.30.1
|
||||
k8s.io/component-base => k8s.io/component-base v0.30.1
|
||||
k8s.io/component-helpers => k8s.io/component-helpers v0.30.1
|
||||
k8s.io/controller-manager => k8s.io/controller-manager v0.30.1
|
||||
k8s.io/cri-api => k8s.io/cri-api v0.30.1
|
||||
k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.30.1
|
||||
k8s.io/dynamic-resource-allocation => k8s.io/dynamic-resource-allocation v0.30.1
|
||||
k8s.io/endpointslice => k8s.io/endpointslice v0.30.1
|
||||
k8s.io/kube-aggregator => k8s.io/kube-aggregator v0.30.1
|
||||
k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v0.30.1
|
||||
k8s.io/kube-proxy => k8s.io/kube-proxy v0.30.1
|
||||
k8s.io/kube-scheduler => k8s.io/kube-scheduler v0.30.1
|
||||
k8s.io/kubectl => k8s.io/kubectl v0.30.1
|
||||
k8s.io/kubelet => k8s.io/kubelet v0.30.1
|
||||
k8s.io/kubernetes => k8s.io/kubernetes v1.30.1
|
||||
k8s.io/legacy-cloud-providers => k8s.io/legacy-cloud-providers v0.30.1
|
||||
k8s.io/metrics => k8s.io/metrics v0.30.1
|
||||
k8s.io/mount-utils => k8s.io/mount-utils v0.30.1
|
||||
k8s.io/pod-security-admission => k8s.io/pod-security-admission v0.30.1
|
||||
k8s.io/sample-apiserver => k8s.io/sample-apiserver v0.30.1
|
||||
sigs.k8s.io/controller-runtime => sigs.k8s.io/controller-runtime v0.18.5
|
||||
)
|
||||
|
||||
replace (
|
||||
github.com/crewjam/saml => github.com/rancher/saml v0.2.0
|
||||
@ -13,16 +52,17 @@ replace (
|
||||
require (
|
||||
github.com/adrg/xdg v0.4.0
|
||||
github.com/golang/mock v1.6.0
|
||||
github.com/golang/protobuf v1.5.4
|
||||
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.16.0
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
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/lasso v0.0.0-20241202185148-04649f379358
|
||||
github.com/rancher/norman v0.0.0-20240708202514-a0127673d1b9
|
||||
github.com/rancher/remotedialer v0.3.2
|
||||
github.com/rancher/wrangler/v3 v3.0.0
|
||||
@ -32,41 +72,43 @@ require (
|
||||
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
|
||||
helm.sh/helm/v3 v3.13.0
|
||||
k8s.io/api v0.31.1
|
||||
k8s.io/apiextensions-apiserver v0.31.0
|
||||
k8s.io/apimachinery v0.31.1
|
||||
k8s.io/apiserver v0.30.1
|
||||
k8s.io/client-go v0.30.1
|
||||
k8s.io/client-go v0.31.1
|
||||
k8s.io/helm v2.17.0+incompatible
|
||||
k8s.io/klog v1.0.0
|
||||
k8s.io/kube-aggregator v0.30.1
|
||||
k8s.io/kube-openapi v0.0.0-20240411171206-dc4e619f62f3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Masterminds/semver/v3 v3.2.1 // indirect
|
||||
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/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // 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/logr v1.4.2 // 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/go-openapi/swag v0.22.4 // 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/imdario/mergo v0.3.13 // 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
|
||||
@ -77,10 +119,10 @@ require (
|
||||
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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.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
|
||||
@ -93,23 +135,22 @@ require (
|
||||
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/crypto v0.24.0 // indirect
|
||||
golang.org/x/net v0.26.0 // indirect
|
||||
golang.org/x/oauth2 v0.21.0 // indirect
|
||||
golang.org/x/sys v0.21.0 // indirect
|
||||
golang.org/x/term v0.21.0 // indirect
|
||||
golang.org/x/text v0.16.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
|
||||
google.golang.org/protobuf v1.34.2 // 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
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // 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
|
||||
@ -121,5 +162,5 @@ require (
|
||||
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
|
||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||
)
|
||||
|
2
main.go
2
main.go
@ -34,7 +34,7 @@ func main() {
|
||||
func run(_ *cli.Context) error {
|
||||
ctx := signals.SetupSignalContext()
|
||||
debugconfig.MustSetupDebug()
|
||||
s, err := config.ToServer(ctx, false)
|
||||
s, err := config.ToServer(ctx, debugconfig.SQLCache)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -108,7 +108,7 @@ func isListOrGetable(schema *types.APISchema) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func isListWatchable(schema *types.APISchema) bool {
|
||||
func IsListWatchable(schema *types.APISchema) bool {
|
||||
var (
|
||||
canList bool
|
||||
canWatch bool
|
||||
@ -163,7 +163,7 @@ func (h *handler) refreshAll(ctx context.Context) error {
|
||||
|
||||
filteredSchemas := map[string]*types.APISchema{}
|
||||
for _, schema := range schemas {
|
||||
if isListWatchable(schema) {
|
||||
if IsListWatchable(schema) {
|
||||
if preferredTypeExists(schema, schemas) {
|
||||
continue
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ import (
|
||||
type Config struct {
|
||||
Debug bool
|
||||
DebugLevel int
|
||||
SQLCache bool
|
||||
}
|
||||
|
||||
func (c *Config) MustSetupDebug() {
|
||||
@ -54,6 +55,10 @@ func Flags(config *Config) []cli.Flag {
|
||||
Value: 7,
|
||||
Destination: &config.DebugLevel,
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "sql-cache",
|
||||
Destination: &config.SQLCache,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,5 +73,9 @@ func FlagsV2(config *Config) []cliv2.Flag {
|
||||
Value: 7,
|
||||
Destination: &config.DebugLevel,
|
||||
},
|
||||
&cliv2.BoolFlag{
|
||||
Name: "sql-cache",
|
||||
Destination: &config.SQLCache,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -104,6 +104,8 @@ func formatter(summarycache *summarycache.SummaryCache) types.Formatter {
|
||||
}
|
||||
|
||||
if unstr, ok := resource.APIObject.Object.(*unstructured.Unstructured); ok {
|
||||
// with the sql cache, these were already added by the indexer. However, the sql cache
|
||||
// is only used for lists, so we need to re-add here for get/watch
|
||||
s, rel := summarycache.SummaryAndRelationship(unstr)
|
||||
data.PutValue(unstr.Object, map[string]interface{}{
|
||||
"name": s.State,
|
||||
|
@ -1,17 +1,59 @@
|
||||
package formatters
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/golang/protobuf/proto"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rancher/apiserver/pkg/types"
|
||||
"github.com/rancher/norman/types/convert"
|
||||
"github.com/rancher/wrangler/v3/pkg/data"
|
||||
"github.com/sirupsen/logrus"
|
||||
"helm.sh/helm/v3/pkg/release"
|
||||
rspb "k8s.io/helm/pkg/proto/hapi/release"
|
||||
)
|
||||
|
||||
func DropHelmData(request *types.APIRequest, resource *types.RawResource) {
|
||||
data := resource.APIObject.Data()
|
||||
if data.String("metadata", "labels", "owner") == "helm" ||
|
||||
data.String("metadata", "labels", "OWNER") == "TILLER" {
|
||||
if data.String("data", "release") != "" {
|
||||
delete(data.Map("data"), "release")
|
||||
var (
|
||||
ErrNotHelmRelease = errors.New("not helm release") // error for when it's not a helm release
|
||||
magicGzip = []byte{0x1f, 0x8b, 0x08} // gzip magic header
|
||||
)
|
||||
|
||||
func HandleHelmData(request *types.APIRequest, resource *types.RawResource) {
|
||||
objData := resource.APIObject.Data()
|
||||
if q := request.Query.Get("includeHelmData"); q == "true" {
|
||||
var helmReleaseData string
|
||||
if resource.Type == "secret" {
|
||||
b, err := base64.StdEncoding.DecodeString(objData.String("data", "release"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
helmReleaseData = string(b)
|
||||
} else {
|
||||
helmReleaseData = objData.String("data", "release")
|
||||
}
|
||||
if objData.String("metadata", "labels", "owner") == "helm" {
|
||||
rl, err := decodeHelm3(helmReleaseData)
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed to decode helm3 release data: %v", err)
|
||||
return
|
||||
}
|
||||
objData.SetNested(rl, "data", "release")
|
||||
}
|
||||
if objData.String("metadata", "labels", "OWNER") == "TILLER" {
|
||||
rl, err := decodeHelm2(helmReleaseData)
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed to decode helm2 release data: %v", err)
|
||||
return
|
||||
}
|
||||
objData.SetNested(rl, "data", "release")
|
||||
}
|
||||
|
||||
} else {
|
||||
DropHelmData(objData)
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,3 +64,78 @@ func Pod(request *types.APIRequest, resource *types.RawResource) {
|
||||
data.SetNested(convert.LowerTitle(fields[2]), "metadata", "state", "name")
|
||||
}
|
||||
}
|
||||
|
||||
// decodeHelm3 receives a helm3 release data string, decodes the string data using the standard base64 library
|
||||
// and unmarshals the data into release.Release struct to return it.
|
||||
func decodeHelm3(data string) (*release.Release, error) {
|
||||
b, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Data is too small to be helm 3 release object
|
||||
if len(b) <= 3 {
|
||||
return nil, ErrNotHelmRelease
|
||||
}
|
||||
|
||||
// For backwards compatibility with releases that were stored before
|
||||
// compression was introduced we skip decompression if the
|
||||
// gzip magic header is not found
|
||||
if bytes.Equal(b[0:3], magicGzip) {
|
||||
r, err := gzip.NewReader(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b2, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b = b2
|
||||
}
|
||||
|
||||
var rls release.Release
|
||||
// unmarshal release object bytes
|
||||
if err := json.Unmarshal(b, &rls); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rls, nil
|
||||
}
|
||||
|
||||
// decodeHelm2 receives a helm2 release data and returns the corresponding helm2 release proto struct
|
||||
func decodeHelm2(data string) (*rspb.Release, error) {
|
||||
b, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// For backwards compatibility with releases that were stored before
|
||||
// compression was introduced we skip decompression if the
|
||||
// gzip magic header is not found
|
||||
if bytes.Equal(b[0:3], magicGzip) {
|
||||
r, err := gzip.NewReader(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b2, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b = b2
|
||||
}
|
||||
|
||||
var rls rspb.Release
|
||||
// unmarshal protobuf bytes
|
||||
if err := proto.Unmarshal(b, &rls); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rls, nil
|
||||
}
|
||||
|
||||
func DropHelmData(data data.Object) {
|
||||
if data.String("metadata", "labels", "owner") == "helm" ||
|
||||
data.String("metadata", "labels", "OWNER") == "TILLER" {
|
||||
if data.String("data", "release") != "" {
|
||||
delete(data.Map("data"), "release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
380
pkg/resources/formatters/formatter_test.go
Normal file
380
pkg/resources/formatters/formatter_test.go
Normal file
@ -0,0 +1,380 @@
|
||||
package formatters
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"github.com/golang/protobuf/proto"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"helm.sh/helm/v3/pkg/chart"
|
||||
"helm.sh/helm/v3/pkg/release"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
pbchart "k8s.io/helm/pkg/proto/hapi/chart"
|
||||
rspb "k8s.io/helm/pkg/proto/hapi/release"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/rancher/apiserver/pkg/types"
|
||||
)
|
||||
|
||||
var r = release.Release{
|
||||
Name: "helmV3Release",
|
||||
Chart: &chart.Chart{
|
||||
Values: map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
},
|
||||
Version: 1,
|
||||
Namespace: "default",
|
||||
}
|
||||
|
||||
var rv2 = rspb.Release{
|
||||
Name: "helmV3Release",
|
||||
Chart: &pbchart.Chart{
|
||||
Metadata: &pbchart.Metadata{
|
||||
Name: "chartName",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Values: &pbchart.Config{
|
||||
Values: map[string]*pbchart.Value{
|
||||
"key": {Value: "value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Version: 1,
|
||||
Namespace: "default",
|
||||
}
|
||||
|
||||
func Test_HandleHelmData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
resource *types.RawResource
|
||||
request *types.APIRequest
|
||||
want *types.RawResource
|
||||
helmVersion int
|
||||
}{ //helm v3
|
||||
{
|
||||
name: "When receiving a SECRET resource with includeHelmData set to TRUE and owner set to HELM, it should decode the helm3 release",
|
||||
resource: newSecret("helm", map[string]interface{}{
|
||||
"release": base64.StdEncoding.EncodeToString([]byte(newV3Release())),
|
||||
}),
|
||||
request: newRequest("true"),
|
||||
want: newSecret("helm", map[string]interface{}{
|
||||
"release": &r,
|
||||
}),
|
||||
helmVersion: 3,
|
||||
},
|
||||
{
|
||||
name: "When receiving a SECRET resource with includeHelmData set to FALSE and owner set to HELM, it should drop the helm data",
|
||||
resource: newSecret("helm", map[string]interface{}{
|
||||
"release": base64.StdEncoding.EncodeToString([]byte(newV3Release())),
|
||||
}),
|
||||
request: newRequest("false"),
|
||||
want: newSecret("helm", map[string]interface{}{}),
|
||||
helmVersion: 3,
|
||||
},
|
||||
{
|
||||
name: "When receiving a SECRET resource WITHOUT the includeHelmData query parameter and owner set to HELM, it should drop the helm data",
|
||||
resource: newSecret("helm", map[string]interface{}{
|
||||
"release": base64.StdEncoding.EncodeToString([]byte(newV3Release())),
|
||||
}),
|
||||
request: newRequest(""),
|
||||
want: newSecret("helm", map[string]interface{}{}),
|
||||
helmVersion: 3,
|
||||
},
|
||||
{
|
||||
name: "When receiving a non-helm SECRET or CONFIGMAP resource with includeHelmData set to TRUE, it shouldn't change the resource",
|
||||
resource: &types.RawResource{
|
||||
Type: "pod",
|
||||
APIObject: types.APIObject{Object: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Pod",
|
||||
"data": map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
}}},
|
||||
},
|
||||
request: newRequest("true"),
|
||||
want: &types.RawResource{
|
||||
Type: "pod",
|
||||
APIObject: types.APIObject{Object: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Pod",
|
||||
"data": map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
}}},
|
||||
},
|
||||
helmVersion: 3,
|
||||
},
|
||||
{
|
||||
name: "When receiving a non-helm SECRET or CONFIGMAP resource with includeHelmData set to FALSE, it shouldn't change the resource",
|
||||
resource: &types.RawResource{
|
||||
Type: "pod",
|
||||
APIObject: types.APIObject{Object: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Pod",
|
||||
"data": map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
}}},
|
||||
},
|
||||
request: newRequest("false"),
|
||||
want: &types.RawResource{
|
||||
Type: "pod",
|
||||
APIObject: types.APIObject{Object: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Pod",
|
||||
"data": map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
}}},
|
||||
},
|
||||
helmVersion: 3,
|
||||
},
|
||||
{
|
||||
name: "When receiving a non-helm SECRET or CONFIGMAP resource WITHOUT the includeHelmData query parameter, it shouldn't change the resource",
|
||||
resource: &types.RawResource{
|
||||
Type: "pod",
|
||||
APIObject: types.APIObject{Object: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Pod",
|
||||
"data": map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
}}},
|
||||
},
|
||||
request: newRequest(""),
|
||||
want: &types.RawResource{
|
||||
Type: "pod",
|
||||
APIObject: types.APIObject{Object: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Pod",
|
||||
"data": map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
}}},
|
||||
},
|
||||
helmVersion: 3,
|
||||
},
|
||||
{
|
||||
name: "When receiving a CONFIGMAP resource with includeHelmData set to TRUE and owner set to HELM, it should decode the helm3 release",
|
||||
resource: newConfigMap("helm", map[string]interface{}{
|
||||
"release": newV3Release(),
|
||||
}),
|
||||
request: newRequest("true"),
|
||||
want: newConfigMap("helm", map[string]interface{}{
|
||||
"release": &r,
|
||||
}),
|
||||
helmVersion: 3,
|
||||
},
|
||||
{
|
||||
name: "When receiving a CONFIGMAP resource with includeHelmData set to FALSE and owner set to HELM, it should drop the helm data",
|
||||
resource: newConfigMap("helm", map[string]interface{}{
|
||||
"release": newV3Release(),
|
||||
}),
|
||||
request: newRequest("false"),
|
||||
want: newConfigMap("helm", map[string]interface{}{}),
|
||||
helmVersion: 3,
|
||||
},
|
||||
{
|
||||
name: "When receiving a CONFIGMAP resource WITHOUT the includeHelmData query parameter and owner set to HELM, it should drop the helm data",
|
||||
resource: newConfigMap("helm", map[string]interface{}{
|
||||
"release": newV3Release(),
|
||||
}),
|
||||
request: newRequest(""),
|
||||
want: newConfigMap("helm", map[string]interface{}{}),
|
||||
helmVersion: 3,
|
||||
},
|
||||
//helm v2
|
||||
{
|
||||
name: "When receiving a SECRET resource with includeHelmData set to TRUE and owner set to TILLER, it should decode the helm2 release",
|
||||
resource: newSecret("TILLER", map[string]interface{}{
|
||||
"release": base64.StdEncoding.EncodeToString([]byte(newV2Release())),
|
||||
}),
|
||||
request: newRequest("true"),
|
||||
want: newSecret("TILLER", map[string]interface{}{
|
||||
"release": &rv2,
|
||||
}),
|
||||
helmVersion: 2,
|
||||
},
|
||||
{
|
||||
name: "When receiving a SECRET resource with includeHelmData set to FALSE and owner set to TILLER, it should drop the helm data",
|
||||
resource: newSecret("TILLER", map[string]interface{}{
|
||||
"release": base64.StdEncoding.EncodeToString([]byte(newV2Release())),
|
||||
}),
|
||||
request: newRequest("false"),
|
||||
want: newSecret("TILLER", map[string]interface{}{}),
|
||||
helmVersion: 2,
|
||||
},
|
||||
{
|
||||
name: "When receiving a SECRET resource WITHOUT the includeHelmData query parameter and owner set to TILLER, it should drop the helm data",
|
||||
resource: newSecret("TILLER", map[string]interface{}{
|
||||
"release": base64.StdEncoding.EncodeToString([]byte(newV2Release())),
|
||||
}),
|
||||
request: newRequest(""),
|
||||
want: newSecret("TILLER", map[string]interface{}{}),
|
||||
helmVersion: 2,
|
||||
},
|
||||
{
|
||||
name: "When receiving a CONFIGMAP resource with includeHelmData set to TRUE and owner set to TILLER, it should decode the helm2 release",
|
||||
resource: newConfigMap("TILLER", map[string]interface{}{
|
||||
"release": newV2Release(),
|
||||
}),
|
||||
request: newRequest("true"),
|
||||
want: newConfigMap("TILLER", map[string]interface{}{
|
||||
"release": &rv2,
|
||||
}),
|
||||
helmVersion: 2,
|
||||
},
|
||||
{
|
||||
name: "When receiving a CONFIGMAP resource with includeHelmData set to FALSE and owner set to TILLER, it should drop the helm data",
|
||||
resource: newConfigMap("TILLER", map[string]interface{}{
|
||||
"release": newV2Release(),
|
||||
}),
|
||||
request: newRequest("false"),
|
||||
want: newConfigMap("TILLER", map[string]interface{}{}),
|
||||
helmVersion: 2,
|
||||
},
|
||||
{
|
||||
name: "When receiving a CONFIGMAP resource WITHOUT the includeHelmData query parameter and owner set to TILLER, it should drop the helm data",
|
||||
resource: newConfigMap("TILLER", map[string]interface{}{
|
||||
"release": newV2Release(),
|
||||
}),
|
||||
request: newRequest(""),
|
||||
want: newConfigMap("TILLER", map[string]interface{}{}),
|
||||
helmVersion: 2,
|
||||
},
|
||||
{
|
||||
name: "[no magic gzip] When receiving a SECRET resource with includeHelmData set to TRUE and owner set to HELM, it should decode the helm3 release",
|
||||
resource: newSecret("helm", map[string]interface{}{
|
||||
"release": base64.StdEncoding.EncodeToString([]byte(newV3ReleaseWithoutGzip())),
|
||||
}),
|
||||
request: newRequest("true"),
|
||||
want: newSecret("helm", map[string]interface{}{
|
||||
"release": &r,
|
||||
}),
|
||||
helmVersion: 3,
|
||||
},
|
||||
{
|
||||
name: "[no magic gzip] When receiving a SECRET resource with includeHelmData set to TRUE and owner set to TILLER, it should decode the helm2 release",
|
||||
resource: newSecret("TILLER", map[string]interface{}{
|
||||
"release": base64.StdEncoding.EncodeToString([]byte(newV2ReleaseWithoutGzip())),
|
||||
}),
|
||||
request: newRequest("true"),
|
||||
want: newSecret("TILLER", map[string]interface{}{
|
||||
"release": &rv2,
|
||||
}),
|
||||
helmVersion: 2,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
HandleHelmData(tt.request, tt.resource)
|
||||
if tt.helmVersion == 2 {
|
||||
u, ok := tt.resource.APIObject.Object.(*unstructured.Unstructured)
|
||||
assert.True(t, ok)
|
||||
rl, ok := u.UnstructuredContent()["data"].(map[string]interface{})["release"]
|
||||
if ok {
|
||||
u, ok = tt.want.APIObject.Object.(*unstructured.Unstructured)
|
||||
assert.True(t, ok)
|
||||
rl2, ok := u.UnstructuredContent()["data"].(map[string]interface{})["release"]
|
||||
assert.True(t, ok)
|
||||
assert.True(t, proto.Equal(rl.(proto.Message), rl2.(proto.Message)))
|
||||
} else {
|
||||
assert.Equal(t, tt.resource, tt.want)
|
||||
}
|
||||
} else {
|
||||
assert.Equal(t, tt.resource, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newSecret(owner string, data map[string]interface{}) *types.RawResource {
|
||||
secret := &unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"data": data,
|
||||
}}
|
||||
if owner == "helm" {
|
||||
secret.SetLabels(map[string]string{"owner": owner})
|
||||
}
|
||||
if owner == "TILLER" {
|
||||
secret.SetLabels(map[string]string{"OWNER": owner})
|
||||
}
|
||||
return &types.RawResource{
|
||||
Type: "secret",
|
||||
APIObject: types.APIObject{Object: secret},
|
||||
}
|
||||
}
|
||||
|
||||
func newConfigMap(owner string, data map[string]interface{}) *types.RawResource {
|
||||
cfgMap := &unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "configmap",
|
||||
"data": data,
|
||||
}}
|
||||
if owner == "helm" {
|
||||
cfgMap.SetLabels(map[string]string{"owner": owner})
|
||||
}
|
||||
if owner == "TILLER" {
|
||||
cfgMap.SetLabels(map[string]string{"OWNER": owner})
|
||||
}
|
||||
return &types.RawResource{
|
||||
Type: "configmap",
|
||||
APIObject: types.APIObject{Object: cfgMap},
|
||||
}
|
||||
}
|
||||
|
||||
func newV2Release() string {
|
||||
a := rv2
|
||||
b, err := proto.Marshal(&a)
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed to marshal release: %v", err)
|
||||
}
|
||||
buf := bytes.Buffer{}
|
||||
gz := gzip.NewWriter(&buf)
|
||||
gz.Write(b)
|
||||
gz.Close()
|
||||
return base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
}
|
||||
|
||||
func newV2ReleaseWithoutGzip() string {
|
||||
a := rv2
|
||||
b, err := proto.Marshal(&a)
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed to marshal release: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func newV3Release() string {
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed to marshal release: %v", err)
|
||||
}
|
||||
buf := bytes.Buffer{}
|
||||
gz := gzip.NewWriter(&buf)
|
||||
gz.Write(b)
|
||||
gz.Close()
|
||||
return base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
}
|
||||
|
||||
func newV3ReleaseWithoutGzip() string {
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed to marshal release: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func newRequest(value string) *types.APIRequest {
|
||||
req := &types.APIRequest{Query: url.Values{}}
|
||||
if value != "" {
|
||||
req.Query.Add("includeHelmData", value)
|
||||
}
|
||||
return req
|
||||
}
|
@ -53,11 +53,11 @@ func DefaultSchemaTemplates(cf *client.Factory,
|
||||
apigroups.Template(discovery),
|
||||
{
|
||||
ID: "configmap",
|
||||
Formatter: formatters.DropHelmData,
|
||||
Formatter: formatters.HandleHelmData,
|
||||
},
|
||||
{
|
||||
ID: "secret",
|
||||
Formatter: formatters.DropHelmData,
|
||||
Formatter: formatters.HandleHelmData,
|
||||
},
|
||||
{
|
||||
ID: "pod",
|
||||
@ -83,11 +83,11 @@ func DefaultSchemaTemplatesForStore(store types.Store,
|
||||
apigroups.Template(discovery),
|
||||
{
|
||||
ID: "configmap",
|
||||
Formatter: formatters.DropHelmData,
|
||||
Formatter: formatters.HandleHelmData,
|
||||
},
|
||||
{
|
||||
ID: "secret",
|
||||
Formatter: formatters.DropHelmData,
|
||||
Formatter: formatters.HandleHelmData,
|
||||
},
|
||||
{
|
||||
ID: "pod",
|
||||
|
103
pkg/resources/virtual/common/common.go
Normal file
103
pkg/resources/virtual/common/common.go
Normal file
@ -0,0 +1,103 @@
|
||||
// Package common provides cache.TransformFunc's which are common to all types
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/rancher/steve/pkg/summarycache"
|
||||
"github.com/rancher/wrangler/v3/pkg/data"
|
||||
wranglerSummary "github.com/rancher/wrangler/v3/pkg/summary"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
// SummaryCache provides an interface to get a summary/relationships for an object. Implemented by the summaryCache
|
||||
// struct from pkg/summarycache
|
||||
type SummaryCache interface {
|
||||
SummaryAndRelationship(runtime.Object) (*wranglerSummary.SummarizedObject, []summarycache.Relationship)
|
||||
}
|
||||
|
||||
// DefaultFields produces a VirtualTransformFunc through GetTransform() that applies to all k8s types
|
||||
type DefaultFields struct {
|
||||
Cache SummaryCache
|
||||
}
|
||||
|
||||
// TransformCommon implements virtual.VirtualTransformFunc, and adds reserved fields/summary
|
||||
func (d *DefaultFields) TransformCommon(obj *unstructured.Unstructured) (*unstructured.Unstructured, error) {
|
||||
obj = addIDField(obj)
|
||||
obj, err := addSummaryFields(obj, d.Cache)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to add summary fields: %w", err)
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// addSummaryFields adds the virtual fields for object state.
|
||||
func addSummaryFields(raw *unstructured.Unstructured, cache SummaryCache) (*unstructured.Unstructured, error) {
|
||||
s, relationships := cache.SummaryAndRelationship(raw)
|
||||
if s != nil {
|
||||
data.PutValue(raw.Object, map[string]interface{}{
|
||||
"name": s.State,
|
||||
"error": s.Error,
|
||||
"transitioning": s.Transitioning,
|
||||
"message": strings.Join(s.Message, ":"),
|
||||
}, "metadata", "state")
|
||||
|
||||
}
|
||||
var rels []any
|
||||
for _, relationship := range relationships {
|
||||
rel, err := toMap(relationship)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to convert relationship to map: %w", err)
|
||||
}
|
||||
rels = append(rels, rel)
|
||||
}
|
||||
data.PutValue(raw.Object, rels, "metadata", "relationships")
|
||||
|
||||
normalizeConditions(raw)
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// addIDField adds the ID field based on namespace/name, and moves the current id field to _id if present
|
||||
func addIDField(raw *unstructured.Unstructured) *unstructured.Unstructured {
|
||||
objectID := raw.GetName()
|
||||
namespace := raw.GetNamespace()
|
||||
if namespace != "" {
|
||||
objectID = fmt.Sprintf("%s/%s", namespace, objectID)
|
||||
}
|
||||
currentIDValue, ok := raw.Object["id"]
|
||||
if ok {
|
||||
raw.Object["_id"] = currentIDValue
|
||||
}
|
||||
raw.Object["id"] = objectID
|
||||
return raw
|
||||
}
|
||||
|
||||
func normalizeConditions(raw *unstructured.Unstructured) {
|
||||
var (
|
||||
obj data.Object
|
||||
newConditions []any
|
||||
)
|
||||
|
||||
obj = raw.Object
|
||||
for _, condition := range obj.Slice("status", "conditions") {
|
||||
var summary wranglerSummary.Summary
|
||||
for _, summarizer := range wranglerSummary.ConditionSummarizers {
|
||||
summary = summarizer(obj, []wranglerSummary.Condition{{Object: condition}}, summary)
|
||||
}
|
||||
condition.Set("error", summary.Error)
|
||||
condition.Set("transitioning", summary.Transitioning)
|
||||
|
||||
if condition.String("lastUpdateTime") == "" {
|
||||
condition.Set("lastUpdateTime", condition.String("lastTransitionTime"))
|
||||
}
|
||||
// needs to be reconverted back to a map[string]any or we can have encoding problems with unregistered types
|
||||
var mapCondition map[string]any = condition
|
||||
newConditions = append(newConditions, mapCondition)
|
||||
}
|
||||
|
||||
if len(newConditions) > 0 {
|
||||
obj.SetNested(newConditions, "status", "conditions")
|
||||
}
|
||||
}
|
187
pkg/resources/virtual/common/common_test.go
Normal file
187
pkg/resources/virtual/common/common_test.go
Normal file
@ -0,0 +1,187 @@
|
||||
package common_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/rancher/steve/pkg/resources/virtual/common"
|
||||
"github.com/rancher/steve/pkg/summarycache"
|
||||
"github.com/rancher/wrangler/v3/pkg/summary"
|
||||
"github.com/stretchr/testify/require"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
)
|
||||
|
||||
func TestTransformCommonObjects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input any
|
||||
hasSummary *summary.SummarizedObject
|
||||
hasRelationships []summarycache.Relationship
|
||||
wantOutput any
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "signal error",
|
||||
input: cache.DeletedFinalStateUnknown{
|
||||
Key: "some-ns/some-name",
|
||||
},
|
||||
wantOutput: cache.DeletedFinalStateUnknown{
|
||||
Key: "some-ns/some-name",
|
||||
},
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "not unstructured",
|
||||
input: map[string]any{
|
||||
"somekey": "someval",
|
||||
},
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "add summary + relationships + reserved fields",
|
||||
hasSummary: &summary.SummarizedObject{
|
||||
PartialObjectMetadata: v1.PartialObjectMetadata{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Name: "testobj",
|
||||
Namespace: "test-ns",
|
||||
},
|
||||
TypeMeta: v1.TypeMeta{
|
||||
APIVersion: "test.cattle.io/v1",
|
||||
Kind: "TestResource",
|
||||
},
|
||||
},
|
||||
Summary: summary.Summary{
|
||||
State: "success",
|
||||
Transitioning: false,
|
||||
Error: false,
|
||||
Message: []string{"resource 1 rolled out", "resource 2 rolled out"},
|
||||
},
|
||||
},
|
||||
hasRelationships: []summarycache.Relationship{
|
||||
{
|
||||
ToID: "1345",
|
||||
ToType: "SomeType",
|
||||
ToNamespace: "some-ns",
|
||||
FromID: "78901",
|
||||
FromType: "TestResource",
|
||||
Rel: "uses",
|
||||
},
|
||||
},
|
||||
input: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "test.cattle.io/v1",
|
||||
"kind": "TestResource",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testobj",
|
||||
"namespace": "test-ns",
|
||||
},
|
||||
"id": "old-id",
|
||||
},
|
||||
},
|
||||
wantOutput: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "test.cattle.io/v1",
|
||||
"kind": "TestResource",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testobj",
|
||||
"namespace": "test-ns",
|
||||
"state": map[string]interface{}{
|
||||
"name": "success",
|
||||
"error": false,
|
||||
"transitioning": false,
|
||||
"message": "resource 1 rolled out:resource 2 rolled out",
|
||||
},
|
||||
"relationships": []any{
|
||||
map[string]any{
|
||||
"toId": "1345",
|
||||
"toType": "SomeType",
|
||||
"toNamespace": "some-ns",
|
||||
"fromId": "78901",
|
||||
"fromType": "TestResource",
|
||||
"rel": "uses",
|
||||
},
|
||||
},
|
||||
},
|
||||
"id": "test-ns/testobj",
|
||||
"_id": "old-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add conditions + reserved fields",
|
||||
input: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "test.cattle.io/v1",
|
||||
"kind": "TestResource",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testobj",
|
||||
"namespace": "test-ns",
|
||||
},
|
||||
"status": map[string]interface{}{
|
||||
"conditions": []interface{}{
|
||||
map[string]interface{}{
|
||||
"status": "False",
|
||||
"reason": "Error",
|
||||
"message": "some error",
|
||||
"lastTransitionTime": "2024-01-01",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantOutput: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "test.cattle.io/v1",
|
||||
"kind": "TestResource",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testobj",
|
||||
"namespace": "test-ns",
|
||||
"relationships": []any(nil),
|
||||
},
|
||||
"status": map[string]interface{}{
|
||||
"conditions": []interface{}{
|
||||
map[string]interface{}{
|
||||
"status": "False",
|
||||
"reason": "Error",
|
||||
"transitioning": false,
|
||||
"error": true,
|
||||
"message": "some error",
|
||||
"lastTransitionTime": "2024-01-01",
|
||||
"lastUpdateTime": "2024-01-01",
|
||||
},
|
||||
},
|
||||
},
|
||||
"id": "test-ns/testobj",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
fakeCache := common.FakeSummaryCache{
|
||||
SummarizedObject: test.hasSummary,
|
||||
Relationships: test.hasRelationships,
|
||||
}
|
||||
df := common.DefaultFields{
|
||||
Cache: &fakeCache,
|
||||
}
|
||||
raw, isSignal, err := common.GetUnstructured(test.input)
|
||||
if err != nil {
|
||||
require.True(t, test.wantError)
|
||||
return
|
||||
}
|
||||
if isSignal {
|
||||
require.Equal(t, test.input, test.wantOutput)
|
||||
return
|
||||
}
|
||||
output, err := df.TransformCommon(raw)
|
||||
if test.wantError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.Equal(t, test.wantOutput, output)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
16
pkg/resources/virtual/common/testutil.go
Normal file
16
pkg/resources/virtual/common/testutil.go
Normal file
@ -0,0 +1,16 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/rancher/steve/pkg/summarycache"
|
||||
"github.com/rancher/wrangler/v3/pkg/summary"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
type FakeSummaryCache struct {
|
||||
SummarizedObject *summary.SummarizedObject
|
||||
Relationships []summarycache.Relationship
|
||||
}
|
||||
|
||||
func (f *FakeSummaryCache) SummaryAndRelationship(runtime.Object) (*summary.SummarizedObject, []summarycache.Relationship) {
|
||||
return f.SummarizedObject, f.Relationships
|
||||
}
|
40
pkg/resources/virtual/common/util.go
Normal file
40
pkg/resources/virtual/common/util.go
Normal file
@ -0,0 +1,40 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
)
|
||||
|
||||
// GetUnstructured retrieves an unstructured object from the provided input. If this is a signal
|
||||
// object (like cache.DeletedFinalStateUnknown), returns true, indicating that this wasn't an
|
||||
// unstructured object, but doesn't need to be processed by our transform function
|
||||
func GetUnstructured(obj any) (*unstructured.Unstructured, bool, error) {
|
||||
raw, ok := obj.(*unstructured.Unstructured)
|
||||
if !ok {
|
||||
_, isFinalUnknown := obj.(cache.DeletedFinalStateUnknown)
|
||||
if isFinalUnknown {
|
||||
// As documented in the TransformFunc interface
|
||||
return nil, true, nil
|
||||
}
|
||||
return nil, false, fmt.Errorf("object was of type %T, not unstructured", raw)
|
||||
}
|
||||
return raw, false, nil
|
||||
}
|
||||
|
||||
// toMap converts an object to a map[string]any which can be stored/retrieved from the cache. Currently
|
||||
// uses json encoding to take advantage of tag names
|
||||
func toMap(obj any) (map[string]any, error) {
|
||||
bytes, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to marshal object: %w", err)
|
||||
}
|
||||
var retObj map[string]any
|
||||
err = json.Unmarshal(bytes, &retObj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to unmarshal object: %w", err)
|
||||
}
|
||||
return retObj, nil
|
||||
}
|
16
pkg/resources/virtual/events/events.go
Normal file
16
pkg/resources/virtual/events/events.go
Normal file
@ -0,0 +1,16 @@
|
||||
// Package common provides cache.TransformFunc's for /v1 Event objects
|
||||
package events
|
||||
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
// TransformEventObject does special-case handling on event objects
|
||||
// 1. (only one so far): replaces the _type field with the contents of the field named "type", if it exists
|
||||
func TransformEventObject(obj *unstructured.Unstructured) (*unstructured.Unstructured, error) {
|
||||
currentTypeValue, ok := obj.Object["type"]
|
||||
if ok {
|
||||
obj.Object["_type"] = currentTypeValue
|
||||
}
|
||||
return obj, nil
|
||||
}
|
93
pkg/resources/virtual/events/events_test.go
Normal file
93
pkg/resources/virtual/events/events_test.go
Normal file
@ -0,0 +1,93 @@
|
||||
package events_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/rancher/steve/pkg/resources/virtual/events"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
func TestTransformEvents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input any
|
||||
wantOutput any
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "fix event fields",
|
||||
input: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "/v1",
|
||||
"kind": "Event",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "gregsFarm",
|
||||
"namespace": "gregsNamespace",
|
||||
},
|
||||
"id": "eventTest1id",
|
||||
"type": "Gorniplatz",
|
||||
},
|
||||
},
|
||||
wantOutput: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "/v1",
|
||||
"kind": "Event",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "gregsFarm",
|
||||
"namespace": "gregsNamespace",
|
||||
},
|
||||
"id": "eventTest1id",
|
||||
"type": "Gorniplatz",
|
||||
"_type": "Gorniplatz",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "don't fix non-default-group event fields",
|
||||
input: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "palau.io/v1",
|
||||
"kind": "Event",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "gregsFarm",
|
||||
"namespace": "gregsNamespace",
|
||||
},
|
||||
"id": "eventTest1id",
|
||||
"type": "Gorniplatz",
|
||||
},
|
||||
},
|
||||
wantOutput: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "palau.io/v1",
|
||||
"kind": "Event",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "gregsFarm",
|
||||
"namespace": "gregsNamespace",
|
||||
},
|
||||
"id": "eventTest1id",
|
||||
"type": "Gorniplatz",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var output interface{}
|
||||
var err error
|
||||
raw, ok := test.input.(*unstructured.Unstructured)
|
||||
if ok && raw.GetKind() == "Event" && raw.GetAPIVersion() == "/v1" {
|
||||
output, err = events.TransformEventObject(raw)
|
||||
} else {
|
||||
output = raw
|
||||
err = nil
|
||||
}
|
||||
require.Equal(t, test.wantOutput, output)
|
||||
if test.wantError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
55
pkg/resources/virtual/virtual.go
Normal file
55
pkg/resources/virtual/virtual.go
Normal file
@ -0,0 +1,55 @@
|
||||
// Package virtual provides functions/resources to define virtual fields (fields which don't exist in k8s
|
||||
// but should be visible in the API) on resources
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/rancher/steve/pkg/resources/virtual/common"
|
||||
"github.com/rancher/steve/pkg/resources/virtual/events"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
)
|
||||
|
||||
// TransformBuilder builds transform functions for specified GVKs through GetTransformFunc
|
||||
type TransformBuilder struct {
|
||||
defaultFields *common.DefaultFields
|
||||
}
|
||||
|
||||
// NewTransformBuilder returns a TransformBuilder using the given summary cache
|
||||
func NewTransformBuilder(cache common.SummaryCache) *TransformBuilder {
|
||||
return &TransformBuilder{
|
||||
defaultFields: &common.DefaultFields{
|
||||
Cache: cache,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetTransformFunc returns the func to transform a raw object into a fixed object, if needed
|
||||
func (t *TransformBuilder) GetTransformFunc(gvk schema.GroupVersionKind) cache.TransformFunc {
|
||||
converters := make([]func(*unstructured.Unstructured) (*unstructured.Unstructured, error), 0)
|
||||
if gvk.Kind == "Event" && gvk.Group == "" && gvk.Version == "v1" {
|
||||
converters = append(converters, events.TransformEventObject)
|
||||
}
|
||||
converters = append(converters, t.defaultFields.TransformCommon)
|
||||
|
||||
return func(raw interface{}) (interface{}, error) {
|
||||
obj, isSignal, err := common.GetUnstructured(raw)
|
||||
if isSignal {
|
||||
// isSignal= true overrides any error
|
||||
return raw, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetUnstructured: failed to get underlying object: %w", err)
|
||||
}
|
||||
// Conversions are run in this loop:
|
||||
for _, f := range converters {
|
||||
obj, err = f(obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
}
|
202
pkg/resources/virtual/virtual_test.go
Normal file
202
pkg/resources/virtual/virtual_test.go
Normal file
@ -0,0 +1,202 @@
|
||||
package virtual_test
|
||||
|
||||
import (
|
||||
"github.com/rancher/steve/pkg/resources/virtual"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rancher/steve/pkg/resources/virtual/common"
|
||||
"github.com/rancher/steve/pkg/summarycache"
|
||||
"github.com/rancher/wrangler/v3/pkg/summary"
|
||||
"github.com/stretchr/testify/require"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
func TestTransformChain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input any
|
||||
hasSummary *summary.SummarizedObject
|
||||
hasRelationships []summarycache.Relationship
|
||||
wantOutput any
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "add summary + relationships + reserved fields",
|
||||
hasSummary: &summary.SummarizedObject{
|
||||
PartialObjectMetadata: v1.PartialObjectMetadata{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Name: "testobj",
|
||||
Namespace: "test-ns",
|
||||
},
|
||||
TypeMeta: v1.TypeMeta{
|
||||
APIVersion: "test.cattle.io/v1",
|
||||
Kind: "TestResource",
|
||||
},
|
||||
},
|
||||
Summary: summary.Summary{
|
||||
State: "success",
|
||||
Transitioning: false,
|
||||
Error: false,
|
||||
Message: []string{"resource 1 rolled out", "resource 2 rolled out"},
|
||||
},
|
||||
},
|
||||
hasRelationships: []summarycache.Relationship{
|
||||
{
|
||||
ToID: "1345",
|
||||
ToType: "SomeType",
|
||||
ToNamespace: "some-ns",
|
||||
FromID: "78901",
|
||||
FromType: "TestResource",
|
||||
Rel: "uses",
|
||||
},
|
||||
},
|
||||
input: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "test.cattle.io/v1",
|
||||
"kind": "TestResource",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testobj",
|
||||
"namespace": "test-ns",
|
||||
},
|
||||
"id": "old-id",
|
||||
},
|
||||
},
|
||||
wantOutput: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "test.cattle.io/v1",
|
||||
"kind": "TestResource",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testobj",
|
||||
"namespace": "test-ns",
|
||||
"state": map[string]interface{}{
|
||||
"name": "success",
|
||||
"error": false,
|
||||
"transitioning": false,
|
||||
"message": "resource 1 rolled out:resource 2 rolled out",
|
||||
},
|
||||
"relationships": []any{
|
||||
map[string]any{
|
||||
"toId": "1345",
|
||||
"toType": "SomeType",
|
||||
"toNamespace": "some-ns",
|
||||
"fromId": "78901",
|
||||
"fromType": "TestResource",
|
||||
"rel": "uses",
|
||||
},
|
||||
},
|
||||
},
|
||||
"id": "test-ns/testobj",
|
||||
"_id": "old-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "processable event",
|
||||
input: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "/v1",
|
||||
"kind": "Event",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "oswaldsFarm",
|
||||
"namespace": "oswaldsNamespace",
|
||||
},
|
||||
"status": map[string]interface{}{
|
||||
"conditions": []interface{}{
|
||||
map[string]interface{}{
|
||||
"status": "False",
|
||||
"reason": "Error",
|
||||
"message": "some error",
|
||||
"lastTransitionTime": "2024-01-01",
|
||||
},
|
||||
},
|
||||
},
|
||||
"id": "eventTest2id",
|
||||
"type": "Gorniplatz",
|
||||
},
|
||||
},
|
||||
wantOutput: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "/v1",
|
||||
"kind": "Event",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "oswaldsFarm",
|
||||
"namespace": "oswaldsNamespace",
|
||||
"relationships": []any(nil),
|
||||
},
|
||||
"status": map[string]interface{}{
|
||||
"conditions": []interface{}{
|
||||
map[string]interface{}{
|
||||
"status": "False",
|
||||
"reason": "Error",
|
||||
"transitioning": false,
|
||||
"error": true,
|
||||
"message": "some error",
|
||||
"lastTransitionTime": "2024-01-01",
|
||||
"lastUpdateTime": "2024-01-01",
|
||||
},
|
||||
},
|
||||
},
|
||||
"id": "oswaldsNamespace/oswaldsFarm",
|
||||
"_id": "eventTest2id",
|
||||
"type": "Gorniplatz",
|
||||
"_type": "Gorniplatz",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "don't fix non-default-group event fields",
|
||||
input: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "palau.io/v1",
|
||||
"kind": "Event",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "gregsFarm",
|
||||
"namespace": "gregsNamespace",
|
||||
"relationships": []any(nil),
|
||||
},
|
||||
"id": "eventTest1id",
|
||||
"type": "Gorniplatz",
|
||||
},
|
||||
},
|
||||
wantOutput: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "palau.io/v1",
|
||||
"kind": "Event",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "gregsFarm",
|
||||
"namespace": "gregsNamespace",
|
||||
"relationships": []any(nil),
|
||||
},
|
||||
"id": "gregsNamespace/gregsFarm",
|
||||
"_id": "eventTest1id",
|
||||
"type": "Gorniplatz",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
fakeCache := common.FakeSummaryCache{
|
||||
SummarizedObject: test.hasSummary,
|
||||
Relationships: test.hasRelationships,
|
||||
}
|
||||
tb := virtual.NewTransformBuilder(&fakeCache)
|
||||
raw, isSignal, err := common.GetUnstructured(test.input)
|
||||
require.False(t, isSignal)
|
||||
require.Nil(t, err)
|
||||
apiVersion := raw.GetAPIVersion()
|
||||
parts := strings.Split(apiVersion, "/")
|
||||
gvk := schema.GroupVersionKind{Group: parts[0], Version: parts[1], Kind: raw.GetKind()}
|
||||
output, err := tb.GetTransformFunc(gvk)(test.input)
|
||||
require.Equal(t, test.wantOutput, output)
|
||||
if test.wantError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -63,6 +63,26 @@ spec:
|
||||
nullable: true
|
||||
served: true
|
||||
storage: true
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: schemaless.management.cattle.io
|
||||
spec:
|
||||
conversion:
|
||||
strategy: None
|
||||
group: management.cattle.io
|
||||
names:
|
||||
kind: Schemaless
|
||||
listKind: SchemalessList
|
||||
plural: schemalese
|
||||
singular: schemaless
|
||||
scope: Cluster
|
||||
preserveUnkownFields: true
|
||||
versions:
|
||||
- name: v2
|
||||
served: true
|
||||
storage: true
|
||||
`
|
||||
)
|
||||
|
||||
@ -346,6 +366,35 @@ definitions:
|
||||
- group: "management.cattle.io"
|
||||
version: "v2"
|
||||
kind: "Nullable"
|
||||
io.cattle.management.v2.Schemaless:
|
||||
description: "this kind has no schema"
|
||||
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: "management.cattle.io"
|
||||
version: "v2"
|
||||
kind: "Schemaless"
|
||||
io.cattle.management.NotAKind:
|
||||
type: "string"
|
||||
description: "Some string which isn't a kind"
|
||||
|
@ -223,7 +223,9 @@ func listGVKModels(models proto.Models, groups *metav1.APIGroupList, crdCache wa
|
||||
Version: version.Name,
|
||||
Kind: crd.Spec.Names.Kind,
|
||||
}
|
||||
gvkToCRD[gvk] = version.Schema.OpenAPIV3Schema
|
||||
if version.Schema != nil {
|
||||
gvkToCRD[gvk] = version.Schema.OpenAPIV3Schema
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -34,7 +34,7 @@ func TestRefresh(t *testing.T) {
|
||||
require.NotNil(t, userAttributesV2)
|
||||
|
||||
nullableV2 := getJSONSchema(crds, "nullable.management.cattle.io", "v2")
|
||||
require.NotNil(t, userAttributesV2)
|
||||
require.NotNil(t, nullableV2)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@ -85,6 +85,11 @@ func TestRefresh(t *testing.T) {
|
||||
Schema: defaultModels.LookupModel("io.cattle.management.v2.Nullable"),
|
||||
CRD: nullableV2,
|
||||
},
|
||||
"management.cattle.io.schemaless": {
|
||||
ModelName: "io.cattle.management.v2.Schemaless",
|
||||
Schema: defaultModels.LookupModel("io.cattle.management.v2.Schemaless"),
|
||||
CRD: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -147,6 +152,11 @@ func TestRefresh(t *testing.T) {
|
||||
Schema: defaultModels.LookupModel("io.cattle.management.v2.Nullable"),
|
||||
CRD: nullableV2,
|
||||
},
|
||||
"management.cattle.io.schemaless": {
|
||||
ModelName: "io.cattle.management.v2.Schemaless",
|
||||
Schema: defaultModels.LookupModel("io.cattle.management.v2.Schemaless"),
|
||||
CRD: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -163,7 +163,7 @@ func setup(ctx context.Context, server *Server) error {
|
||||
|
||||
var onSchemasHandler schemacontroller.SchemasHandlerFunc
|
||||
if server.SQLCache {
|
||||
s, err := sqlproxy.NewProxyStore(cols, cf, summaryCache, nil)
|
||||
s, err := sqlproxy.NewProxyStore(cols, cf, summaryCache, summaryCache, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ package partition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
@ -110,7 +111,7 @@ func (s *Store) Delete(apiOp *types.APIRequest, schema *types.APISchema, id stri
|
||||
if err != nil {
|
||||
return types.APIObject{}, err
|
||||
}
|
||||
return ToAPI(schema, obj, warnings), nil
|
||||
return ToAPI(schema, obj, warnings, types.ReservedFields), nil
|
||||
}
|
||||
|
||||
// ByID looks up a single object by its ID.
|
||||
@ -124,7 +125,7 @@ func (s *Store) ByID(apiOp *types.APIRequest, schema *types.APISchema, id string
|
||||
if err != nil {
|
||||
return types.APIObject{}, err
|
||||
}
|
||||
return ToAPI(schema, obj, warnings), nil
|
||||
return ToAPI(schema, obj, warnings, types.ReservedFields), nil
|
||||
}
|
||||
|
||||
func (s *Store) listPartition(ctx context.Context, apiOp *types.APIRequest, schema *types.APISchema, partition Partition,
|
||||
@ -226,7 +227,7 @@ func (s *Store) List(apiOp *types.APIRequest, schema *types.APISchema) (types.AP
|
||||
|
||||
for _, item := range list {
|
||||
item := item.DeepCopy()
|
||||
result.Objects = append(result.Objects, ToAPI(schema, item, nil))
|
||||
result.Objects = append(result.Objects, ToAPI(schema, item, nil, types.ReservedFields))
|
||||
}
|
||||
|
||||
result.Pages = pages
|
||||
@ -266,7 +267,7 @@ func (s *Store) Create(apiOp *types.APIRequest, schema *types.APISchema, data ty
|
||||
if err != nil {
|
||||
return types.APIObject{}, err
|
||||
}
|
||||
return ToAPI(schema, obj, warnings), nil
|
||||
return ToAPI(schema, obj, warnings, types.ReservedFields), nil
|
||||
}
|
||||
|
||||
// Update updates a single object in the store.
|
||||
@ -280,7 +281,7 @@ func (s *Store) Update(apiOp *types.APIRequest, schema *types.APISchema, data ty
|
||||
if err != nil {
|
||||
return types.APIObject{}, err
|
||||
}
|
||||
return ToAPI(schema, obj, warnings), nil
|
||||
return ToAPI(schema, obj, warnings, types.ReservedFields), nil
|
||||
}
|
||||
|
||||
// Watch returns a channel of events for a list or resource.
|
||||
@ -326,13 +327,13 @@ func (s *Store) Watch(apiOp *types.APIRequest, schema *types.APISchema, wr types
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func ToAPI(schema *types.APISchema, obj runtime.Object, warnings []types.Warning) types.APIObject {
|
||||
func ToAPI(schema *types.APISchema, obj runtime.Object, warnings []types.Warning, reservedFields map[string]bool) types.APIObject {
|
||||
if obj == nil || reflect.ValueOf(obj).IsNil() {
|
||||
return types.APIObject{}
|
||||
}
|
||||
|
||||
if unstr, ok := obj.(*unstructured.Unstructured); ok {
|
||||
obj = moveToUnderscore(unstr)
|
||||
obj = moveToUnderscore(unstr, reservedFields)
|
||||
}
|
||||
|
||||
apiObject := types.APIObject{
|
||||
@ -356,12 +357,12 @@ func ToAPI(schema *types.APISchema, obj runtime.Object, warnings []types.Warning
|
||||
return apiObject
|
||||
}
|
||||
|
||||
func moveToUnderscore(obj *unstructured.Unstructured) *unstructured.Unstructured {
|
||||
func moveToUnderscore(obj *unstructured.Unstructured, reservedFields map[string]bool) *unstructured.Unstructured {
|
||||
if obj == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for k := range types.ReservedFields {
|
||||
for k := range reservedFields {
|
||||
v, ok := obj.Object[k]
|
||||
if ok {
|
||||
delete(obj.Object, k)
|
||||
@ -389,11 +390,11 @@ func ToAPIEvent(apiOp *types.APIRequest, schema *types.APISchema, event watch.Ev
|
||||
|
||||
if event.Type == watch.Error {
|
||||
status, _ := event.Object.(*metav1.Status)
|
||||
apiEvent.Error = fmt.Errorf(status.Message)
|
||||
apiEvent.Error = errors.New(status.Message)
|
||||
return apiEvent
|
||||
}
|
||||
|
||||
apiEvent.Object = ToAPI(schema, event.Object, nil)
|
||||
apiEvent.Object = ToAPI(schema, event.Object, nil, types.ReservedFields)
|
||||
|
||||
m, err := meta.Accessor(event.Object)
|
||||
if err != nil {
|
||||
|
@ -13,15 +13,6 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rancher/apiserver/pkg/types"
|
||||
"github.com/rancher/steve/pkg/accesscontrol"
|
||||
"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/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"
|
||||
@ -34,9 +25,25 @@ import (
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
"github.com/rancher/apiserver/pkg/apierror"
|
||||
"github.com/rancher/apiserver/pkg/types"
|
||||
"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/rancher/steve/pkg/accesscontrol"
|
||||
"github.com/rancher/steve/pkg/attributes"
|
||||
metricsStore "github.com/rancher/steve/pkg/stores/metrics"
|
||||
"github.com/rancher/steve/pkg/stores/partition"
|
||||
)
|
||||
|
||||
const watchTimeoutEnv = "CATTLE_WATCH_TIMEOUT_SECONDS"
|
||||
const (
|
||||
watchTimeoutEnv = "CATTLE_WATCH_TIMEOUT_SECONDS"
|
||||
errNamespaceRequired = "metadata.namespace is required"
|
||||
errResourceVersionRequired = "metadata.resourceVersion is required for update"
|
||||
)
|
||||
|
||||
var (
|
||||
lowerChars = regexp.MustCompile("[a-z]+")
|
||||
@ -422,20 +429,27 @@ func (s *Store) Create(apiOp *types.APIRequest, schema *types.APISchema, params
|
||||
}
|
||||
|
||||
name := types.Name(input)
|
||||
ns := types.Namespace(input)
|
||||
if name == "" && input.String("metadata", "generateName") == "" {
|
||||
input.SetNested(schema.ID[0:1]+"-", "metadata", "generatedName")
|
||||
namespace := types.Namespace(input)
|
||||
generateName := input.String("metadata", "generateName")
|
||||
|
||||
if name == "" && generateName == "" {
|
||||
input.SetNested(schema.ID[0:1]+"-", "metadata", "generateName")
|
||||
}
|
||||
if ns == "" && apiOp.Namespace != "" {
|
||||
ns = apiOp.Namespace
|
||||
input.SetNested(ns, "metadata", "namespace")
|
||||
|
||||
if attributes.Namespaced(schema) && namespace == "" {
|
||||
if apiOp.Namespace == "" {
|
||||
return nil, nil, apierror.NewAPIError(validation.InvalidBodyContent, errNamespaceRequired)
|
||||
}
|
||||
|
||||
namespace = apiOp.Namespace
|
||||
input.SetNested(namespace, "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))
|
||||
k8sClient, err := metricsStore.Wrap(s.clientGetter.TableClient(apiOp, schema, namespace, &buffer))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@ -502,9 +516,12 @@ func (s *Store) Update(apiOp *types.APIRequest, schema *types.APISchema, params
|
||||
|
||||
resourceVersion := input.String("metadata", "resourceVersion")
|
||||
if resourceVersion == "" {
|
||||
return nil, nil, fmt.Errorf("metadata.resourceVersion is required for update")
|
||||
return nil, nil, errors.New(errResourceVersionRequired)
|
||||
}
|
||||
|
||||
gvk := attributes.GVK(schema)
|
||||
input["apiVersion"], input["kind"] = gvk.ToAPIVersionAndKind()
|
||||
|
||||
opts := metav1.UpdateOptions{}
|
||||
if err := decodeParams(apiOp, &opts); err != nil {
|
||||
return nil, nil, err
|
||||
|
@ -2,26 +2,35 @@ package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
schema2 "k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/client-go/dynamic"
|
||||
|
||||
"github.com/rancher/wrangler/v3/pkg/schemas/validation"
|
||||
|
||||
"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/apis/meta/v1/unstructured"
|
||||
"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"
|
||||
|
||||
"github.com/rancher/apiserver/pkg/apierror"
|
||||
"github.com/rancher/apiserver/pkg/types"
|
||||
"github.com/rancher/wrangler/v3/pkg/schemas"
|
||||
|
||||
"github.com/rancher/steve/pkg/client"
|
||||
)
|
||||
|
||||
var c *watch.FakeWatcher
|
||||
@ -32,6 +41,14 @@ type testFactory struct {
|
||||
fakeClient *fake.FakeDynamicClient
|
||||
}
|
||||
|
||||
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 (t *testFactory) TableClient(ctx *types.APIRequest, schema *types.APISchema, namespace string, warningHandler rest.WarningHandler) (dynamic.ResourceInterface, error) {
|
||||
return t.fakeClient.Resource(schema2.GroupVersionResource{}).Namespace(namespace), nil
|
||||
}
|
||||
|
||||
func TestWatchNamesErrReceive(t *testing.T) {
|
||||
testClientFactory, err := client.NewFactory(&rest.Config{}, false)
|
||||
assert.Nil(t, err)
|
||||
@ -80,10 +97,6 @@ func TestByNames(t *testing.T) {
|
||||
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()
|
||||
@ -121,3 +134,711 @@ func receiveUntil(wc chan watch.Event, d time.Duration) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate(t *testing.T) {
|
||||
type input struct {
|
||||
apiOp *types.APIRequest
|
||||
schema *types.APISchema
|
||||
params types.APIObject
|
||||
}
|
||||
|
||||
type expected struct {
|
||||
value *unstructured.Unstructured
|
||||
warning []types.Warning
|
||||
err error
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
namespace string
|
||||
input input
|
||||
expected expected
|
||||
createReactorFunc clientgotesting.ReactionFunc
|
||||
}{
|
||||
{
|
||||
name: "creating resource - namespace scoped",
|
||||
input: input{
|
||||
apiOp: &types.APIRequest{
|
||||
Schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
},
|
||||
},
|
||||
Request: &http.Request{URL: &url.URL{}},
|
||||
},
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
Attributes: map[string]interface{}{
|
||||
"version": "v1",
|
||||
"kind": "Secret",
|
||||
"namespaced": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
params: types.APIObject{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testing-secret",
|
||||
"namespace": "testing-ns",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
createReactorFunc: func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) {
|
||||
return false, ret, nil
|
||||
},
|
||||
expected: expected{
|
||||
value: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testing-secret",
|
||||
"namespace": "testing-ns",
|
||||
},
|
||||
}},
|
||||
warning: []types.Warning{},
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "creating resource - cluster scoped",
|
||||
input: input{
|
||||
apiOp: &types.APIRequest{
|
||||
Schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
},
|
||||
},
|
||||
Request: &http.Request{URL: &url.URL{}},
|
||||
},
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
Attributes: map[string]interface{}{
|
||||
"version": "v1",
|
||||
"kind": "Secret",
|
||||
"namespaced": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
params: types.APIObject{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testing-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
createReactorFunc: func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) {
|
||||
return false, ret, nil
|
||||
},
|
||||
expected: expected{
|
||||
value: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testing-secret",
|
||||
},
|
||||
}},
|
||||
warning: []types.Warning{},
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing name",
|
||||
input: input{
|
||||
apiOp: &types.APIRequest{
|
||||
Schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
},
|
||||
},
|
||||
Request: &http.Request{URL: &url.URL{}},
|
||||
},
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
Attributes: map[string]interface{}{
|
||||
"version": "v1",
|
||||
"kind": "Secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
params: types.APIObject{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"namespace": "testing-ns",
|
||||
"generateName": "testing-gen-name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
createReactorFunc: func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) {
|
||||
return false, ret, nil
|
||||
},
|
||||
expected: expected{
|
||||
value: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"generateName": "testing-gen-name",
|
||||
"namespace": "testing-ns",
|
||||
},
|
||||
}},
|
||||
warning: []types.Warning{},
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing name / generateName",
|
||||
input: input{
|
||||
apiOp: &types.APIRequest{
|
||||
Schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
},
|
||||
},
|
||||
Request: &http.Request{URL: &url.URL{}},
|
||||
},
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
Attributes: map[string]interface{}{
|
||||
"version": "v1",
|
||||
"kind": "Secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
params: types.APIObject{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"namespace": "testing-ns",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
createReactorFunc: func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) {
|
||||
return false, ret, nil
|
||||
},
|
||||
expected: expected{
|
||||
value: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"generateName": "t-",
|
||||
"namespace": "testing-ns",
|
||||
},
|
||||
}},
|
||||
warning: []types.Warning{},
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing namespace in the params (should copy from apiOp)",
|
||||
input: input{
|
||||
apiOp: &types.APIRequest{
|
||||
Schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
},
|
||||
},
|
||||
Namespace: "testing-ns",
|
||||
Request: &http.Request{URL: &url.URL{}},
|
||||
},
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
Attributes: map[string]interface{}{
|
||||
"version": "v1",
|
||||
"kind": "Secret",
|
||||
"namespaced": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
params: types.APIObject{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testing-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
createReactorFunc: func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) {
|
||||
return false, ret, nil
|
||||
},
|
||||
expected: expected{
|
||||
value: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testing-secret",
|
||||
"namespace": "testing-ns",
|
||||
},
|
||||
}},
|
||||
warning: []types.Warning{},
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing namespace - namespace scoped",
|
||||
input: input{
|
||||
apiOp: &types.APIRequest{
|
||||
Schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
},
|
||||
},
|
||||
Request: &http.Request{URL: &url.URL{}},
|
||||
},
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
params: types.APIObject{},
|
||||
},
|
||||
expected: expected{
|
||||
value: nil,
|
||||
warning: nil,
|
||||
err: apierror.NewAPIError(
|
||||
validation.InvalidBodyContent,
|
||||
errNamespaceRequired,
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error response",
|
||||
input: input{
|
||||
apiOp: &types.APIRequest{
|
||||
Schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
},
|
||||
},
|
||||
Request: &http.Request{URL: &url.URL{}},
|
||||
},
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
Attributes: map[string]interface{}{
|
||||
"version": "v1",
|
||||
"kind": "Secret",
|
||||
"namespaced": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
params: types.APIObject{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testing-secret",
|
||||
"namespace": "testing-ns",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
createReactorFunc: func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) {
|
||||
return true, nil, apierrors.NewUnauthorized("sample reason")
|
||||
},
|
||||
expected: expected{
|
||||
value: nil,
|
||||
warning: []types.Warning{},
|
||||
err: apierrors.NewUnauthorized("sample reason"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testClientFactory, err := client.NewFactory(&rest.Config{}, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
fakeClient := fake.NewSimpleDynamicClient(runtime.NewScheme())
|
||||
|
||||
if tt.createReactorFunc != nil {
|
||||
fakeClient.PrependReactor("create", "*", tt.createReactorFunc)
|
||||
}
|
||||
|
||||
testStore := Store{
|
||||
clientGetter: &testFactory{Factory: testClientFactory,
|
||||
fakeClient: fakeClient,
|
||||
},
|
||||
}
|
||||
|
||||
value, warning, err := testStore.Create(tt.input.apiOp, tt.input.schema, tt.input.params)
|
||||
|
||||
assert.Equal(t, tt.expected.value, value)
|
||||
assert.Equal(t, tt.expected.warning, warning)
|
||||
assert.Equal(t, tt.expected.err, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate(t *testing.T) {
|
||||
type input struct {
|
||||
apiOp *types.APIRequest
|
||||
schema *types.APISchema
|
||||
params types.APIObject
|
||||
id string
|
||||
}
|
||||
|
||||
type expected struct {
|
||||
value *unstructured.Unstructured
|
||||
warning []types.Warning
|
||||
err error
|
||||
}
|
||||
|
||||
sampleCreateInput := input{
|
||||
apiOp: &types.APIRequest{
|
||||
Request: &http.Request{
|
||||
URL: &url.URL{},
|
||||
Method: http.MethodPost,
|
||||
},
|
||||
Schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
},
|
||||
},
|
||||
Method: http.MethodPost,
|
||||
},
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
Attributes: map[string]interface{}{
|
||||
"kind": "Secret",
|
||||
"version": "v1",
|
||||
"namespaced": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
params: types.APIObject{
|
||||
Object: map[string]interface{}{
|
||||
"kind": "Secret",
|
||||
"apiVersion": "v1",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testing-secret",
|
||||
"namespace": "testing-ns",
|
||||
"resourceVersion": "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
updateCallbackFunc clientgotesting.ReactionFunc
|
||||
createInput *input
|
||||
updateInput input
|
||||
expected expected
|
||||
}{
|
||||
{
|
||||
name: "update - usual request",
|
||||
updateCallbackFunc: func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) {
|
||||
return false, ret, nil
|
||||
},
|
||||
createInput: &sampleCreateInput,
|
||||
updateInput: input{
|
||||
apiOp: &types.APIRequest{
|
||||
Request: &http.Request{
|
||||
URL: &url.URL{},
|
||||
Method: http.MethodPut,
|
||||
},
|
||||
Schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
},
|
||||
},
|
||||
Method: http.MethodPut,
|
||||
},
|
||||
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
Attributes: map[string]interface{}{
|
||||
"version": "v2",
|
||||
"kind": "Secret",
|
||||
"namespaced": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
params: types.APIObject{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v2",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testing-secret",
|
||||
"namespace": "testing-ns",
|
||||
"resourceVersion": "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: expected{
|
||||
value: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": "v2",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testing-secret",
|
||||
"namespace": "testing-ns",
|
||||
"resourceVersion": "1",
|
||||
},
|
||||
}},
|
||||
warning: []types.Warning{},
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update - different apiVersion and kind (params and schema) - should copy from schema",
|
||||
updateCallbackFunc: func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) {
|
||||
return false, ret, nil
|
||||
},
|
||||
createInput: &sampleCreateInput,
|
||||
updateInput: input{
|
||||
apiOp: &types.APIRequest{
|
||||
Request: &http.Request{
|
||||
URL: &url.URL{},
|
||||
Method: http.MethodPut,
|
||||
},
|
||||
Schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
},
|
||||
},
|
||||
Method: http.MethodPut,
|
||||
},
|
||||
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
Attributes: map[string]interface{}{
|
||||
"version": "v2",
|
||||
"kind": "Secret",
|
||||
"namespaced": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
params: types.APIObject{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "ConfigMap",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testing-secret",
|
||||
"namespace": "testing-ns",
|
||||
"resourceVersion": "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: expected{
|
||||
value: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": "v2",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testing-secret",
|
||||
"namespace": "testing-ns",
|
||||
"resourceVersion": "1",
|
||||
},
|
||||
}},
|
||||
warning: []types.Warning{},
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update - missing apiVersion and kind in params - should copy from schema",
|
||||
updateCallbackFunc: func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) {
|
||||
return false, ret, nil
|
||||
},
|
||||
createInput: &sampleCreateInput,
|
||||
updateInput: input{
|
||||
apiOp: &types.APIRequest{
|
||||
Request: &http.Request{
|
||||
URL: &url.URL{},
|
||||
Method: http.MethodPost,
|
||||
},
|
||||
Schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
},
|
||||
},
|
||||
Method: http.MethodPost,
|
||||
},
|
||||
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
Attributes: map[string]interface{}{
|
||||
"version": "v2",
|
||||
"kind": "Secret",
|
||||
"namespaced": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
params: types.APIObject{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testing-secret",
|
||||
"namespace": "testing-ns",
|
||||
"resourceVersion": "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: expected{
|
||||
value: &unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": "v2",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testing-secret",
|
||||
"namespace": "testing-ns",
|
||||
"resourceVersion": "1",
|
||||
},
|
||||
}},
|
||||
warning: []types.Warning{},
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update - missing resource version",
|
||||
updateCallbackFunc: func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) {
|
||||
return false, ret, nil
|
||||
},
|
||||
updateInput: input{
|
||||
apiOp: &types.APIRequest{
|
||||
Request: &http.Request{
|
||||
URL: &url.URL{},
|
||||
Method: http.MethodPut,
|
||||
},
|
||||
Schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
},
|
||||
},
|
||||
Method: http.MethodPut,
|
||||
},
|
||||
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
Attributes: map[string]interface{}{
|
||||
"version": "v1",
|
||||
"kind": "Secret",
|
||||
"namespaced": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
params: types.APIObject{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testing-secret",
|
||||
"namespace": "testing-ns",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: expected{
|
||||
value: nil,
|
||||
warning: nil,
|
||||
err: errors.New(errResourceVersionRequired),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update - error request",
|
||||
updateCallbackFunc: func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) {
|
||||
return true, nil, apierrors.NewUnauthorized("sample reason")
|
||||
},
|
||||
createInput: &sampleCreateInput,
|
||||
updateInput: input{
|
||||
apiOp: &types.APIRequest{
|
||||
Request: &http.Request{
|
||||
URL: &url.URL{},
|
||||
Method: http.MethodPut,
|
||||
},
|
||||
Schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
},
|
||||
},
|
||||
Method: http.MethodPut,
|
||||
},
|
||||
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "testing",
|
||||
Attributes: map[string]interface{}{
|
||||
"kind": "Secret",
|
||||
"namespaced": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
params: types.APIObject{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v2",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "testing-secret",
|
||||
"namespace": "testing-ns",
|
||||
"resourceVersion": "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: expected{
|
||||
value: nil,
|
||||
warning: nil,
|
||||
err: apierrors.NewUnauthorized("sample reason"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testClientFactory, err := client.NewFactory(&rest.Config{}, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
fakeClient := fake.NewSimpleDynamicClient(runtime.NewScheme())
|
||||
|
||||
if tt.updateCallbackFunc != nil {
|
||||
fakeClient.PrependReactor("update", "*", tt.updateCallbackFunc)
|
||||
}
|
||||
|
||||
testStore := Store{
|
||||
clientGetter: &testFactory{Factory: testClientFactory,
|
||||
fakeClient: fakeClient,
|
||||
},
|
||||
}
|
||||
|
||||
// Creating the object first, so we can update it later (this function is not the SUT)
|
||||
if tt.createInput != nil {
|
||||
_, _, err = testStore.Create(tt.createInput.apiOp, tt.createInput.schema, tt.createInput.params)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
value, warning, err := testStore.Update(tt.updateInput.apiOp, tt.updateInput.schema, tt.updateInput.params, tt.updateInput.id)
|
||||
|
||||
assert.Equal(t, tt.expected.value, value)
|
||||
assert.Equal(t, tt.expected.warning, warning)
|
||||
|
||||
if tt.expected.err != nil {
|
||||
assert.Equal(t, tt.expected.err.Error(), err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import (
|
||||
"github.com/rancher/steve/pkg/attributes"
|
||||
"github.com/rancher/steve/pkg/stores/partition"
|
||||
"github.com/rancher/wrangler/v3/pkg/kv"
|
||||
"github.com/sirupsen/logrus"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
@ -64,17 +65,10 @@ func (p *rbacPartitioner) All(apiOp *types.APIRequest, schema *types.APISchema,
|
||||
fallthrough
|
||||
case "watch":
|
||||
if id != "" {
|
||||
ns, name := kv.RSplit(id, "/")
|
||||
return []partition.Partition{
|
||||
Partition{
|
||||
Namespace: ns,
|
||||
All: false,
|
||||
Passthrough: false,
|
||||
Names: sets.NewString(name),
|
||||
},
|
||||
}, nil
|
||||
partitions := generatePartitionsByID(apiOp, schema, verb, id)
|
||||
return partitions, nil
|
||||
}
|
||||
partitions, passthrough := isPassthrough(apiOp, schema, verb)
|
||||
partitions, passthrough := generateAggregatePartitions(apiOp, schema, verb)
|
||||
if passthrough {
|
||||
return passthroughPartitions, nil
|
||||
}
|
||||
@ -126,15 +120,92 @@ 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) {
|
||||
// generatePartitionsById determines whether a requester can access a particular resource
|
||||
// and if so, returns the corresponding partitions
|
||||
func generatePartitionsByID(apiOp *types.APIRequest, schema *types.APISchema, verb string, id string) []partition.Partition {
|
||||
accessListByVerb, _ := attributes.Access(schema).(accesscontrol.AccessListByVerb)
|
||||
resources := accessListByVerb.Granted(verb)
|
||||
|
||||
idNamespace, name := kv.RSplit(id, "/")
|
||||
apiNamespace := apiOp.Namespace
|
||||
effectiveNamespace := idNamespace
|
||||
|
||||
// If a non-empty namespace was provided, be sure to select that for filtering and permissions checks
|
||||
if idNamespace == "" && apiNamespace != "" {
|
||||
effectiveNamespace = apiNamespace
|
||||
}
|
||||
|
||||
// The external API is flexible, and permits specifying a namespace as a separate key or embedded
|
||||
// within the ID of the object. Both of these cases should be valid:
|
||||
// {"namespace": "n1", "id": "r1"}
|
||||
// {"id": "n1/r1"}
|
||||
// however, the following conflicting request is not valid, but was previously accepted:
|
||||
// {"namespace": "n1", "id": "n2/r1"}
|
||||
// To avoid breaking UI plugins that may inadvertently rely on the feature, we issue a deprecation
|
||||
// warning for now. We still need to pick one of the namespaces for permission verification purposes.
|
||||
if idNamespace != "" && apiNamespace != "" && idNamespace != apiNamespace {
|
||||
logrus.Warningf("DEPRECATION: Conflicting namespaces '%v' and '%v' requested. "+
|
||||
"Selecting '%v' as the effective namespace. Future steve versions will reject this request.",
|
||||
idNamespace, apiNamespace, effectiveNamespace)
|
||||
}
|
||||
|
||||
if accessListByVerb.All(verb) {
|
||||
return []partition.Partition{
|
||||
Partition{
|
||||
Namespace: effectiveNamespace,
|
||||
All: false,
|
||||
Passthrough: false,
|
||||
Names: sets.NewString(name),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if effectiveNamespace != "" {
|
||||
if resources[effectiveNamespace].All {
|
||||
return []partition.Partition{
|
||||
Partition{
|
||||
Namespace: effectiveNamespace,
|
||||
All: false,
|
||||
Passthrough: false,
|
||||
Names: sets.NewString(name),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For cluster-scoped resources, we will have parsed a "" out
|
||||
// of the ID field from RSplit, but accessListByVerb specifies "*" for
|
||||
// the namespace, so correct that here
|
||||
resourceNamespace := effectiveNamespace
|
||||
if resourceNamespace == "" {
|
||||
resourceNamespace = accesscontrol.All
|
||||
}
|
||||
|
||||
nameset, ok := resources[resourceNamespace]
|
||||
if ok && nameset.Names.Has(name) {
|
||||
return []partition.Partition{
|
||||
Partition{
|
||||
Namespace: effectiveNamespace,
|
||||
All: false,
|
||||
Passthrough: false,
|
||||
Names: sets.NewString(name),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateAggregatePartitions 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 generateAggregatePartitions(apiOp *types.APIRequest, schema *types.APISchema, verb string) ([]partition.Partition, bool) {
|
||||
accessListByVerb, _ := attributes.Access(schema).(accesscontrol.AccessListByVerb)
|
||||
resources := accessListByVerb.Granted(verb)
|
||||
|
||||
if accessListByVerb.All(verb) {
|
||||
return nil, true
|
||||
}
|
||||
|
||||
resources := accessListByVerb.Granted(verb)
|
||||
if apiOp.Namespace != "" {
|
||||
if resources[apiOp.Namespace].All {
|
||||
return nil, true
|
||||
|
@ -11,7 +11,7 @@ import (
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
)
|
||||
|
||||
func TestAll(t *testing.T) {
|
||||
func TestVerbList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiOp *types.APIRequest
|
||||
@ -223,7 +223,7 @@ func TestAll(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "by id",
|
||||
name: "by id fully unauthorized",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
@ -231,6 +231,72 @@ func TestAll(t *testing.T) {
|
||||
ID: "foo",
|
||||
},
|
||||
},
|
||||
wantPartitions: nil,
|
||||
},
|
||||
{
|
||||
name: "by id missing namespace",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": true,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"list": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "n2",
|
||||
ResourceName: "r2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: nil,
|
||||
},
|
||||
{
|
||||
name: "by id missing resource",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
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: "r2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: nil,
|
||||
},
|
||||
{
|
||||
name: "by id authorized by name",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
Partition{
|
||||
Namespace: "n1",
|
||||
@ -238,8 +304,227 @@ func TestAll(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "by id authorized by namespace",
|
||||
apiOp: &types.APIRequest{
|
||||
Namespace: "n1",
|
||||
},
|
||||
id: "n1/r1",
|
||||
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: "n1",
|
||||
Names: sets.NewString("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "by namespaced id authorized by name",
|
||||
apiOp: &types.APIRequest{
|
||||
Namespace: "n1",
|
||||
},
|
||||
id: "r1",
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
Partition{
|
||||
Namespace: "n1",
|
||||
Names: sets.NewString("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "by id ignores unrequested resources",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
Partition{
|
||||
Namespace: "n1",
|
||||
Names: sets.NewString("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
// Note: this is deprecated fallback behavior. When we remove the behavior,
|
||||
// rewrite this test to expect an error instead.
|
||||
{
|
||||
name: "by id prefers id embedded namespace",
|
||||
apiOp: &types.APIRequest{
|
||||
Namespace: "n2",
|
||||
},
|
||||
id: "n1/r1",
|
||||
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: "r2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
Partition{
|
||||
Namespace: "n1",
|
||||
Names: sets.NewString("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cluster scoped id unauthorized",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "c1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
},
|
||||
},
|
||||
wantPartitions: nil,
|
||||
},
|
||||
{
|
||||
name: "cluster scoped id authorized by name",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "c1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": false,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"list": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "*",
|
||||
ResourceName: "c1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
Partition{
|
||||
Namespace: "",
|
||||
Names: sets.NewString("c1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cluster scoped id authorized globally",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "c1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": false,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"list": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "*",
|
||||
ResourceName: "*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
Partition{
|
||||
Namespace: "",
|
||||
Names: sets.NewString("c1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cluster scoped id ignores unrequested resources",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "c1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": false,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"list": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "*",
|
||||
ResourceName: "c1",
|
||||
},
|
||||
accesscontrol.Access{
|
||||
Namespace: "*",
|
||||
ResourceName: "c2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
Partition{
|
||||
Namespace: "",
|
||||
Names: sets.NewString("c1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
partitioner := rbacPartitioner{}
|
||||
@ -250,3 +535,325 @@ func TestAll(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerbWatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiOp *types.APIRequest
|
||||
id string
|
||||
schema *types.APISchema
|
||||
wantPartitions []partition.Partition
|
||||
}{
|
||||
{
|
||||
name: "by id fully unauthorized",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
},
|
||||
},
|
||||
wantPartitions: nil,
|
||||
},
|
||||
{
|
||||
name: "by id missing namespace",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": true,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "n2",
|
||||
ResourceName: "r2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: nil,
|
||||
},
|
||||
{
|
||||
name: "by id missing resource",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": true,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "n1",
|
||||
ResourceName: "r2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: nil,
|
||||
},
|
||||
{
|
||||
name: "by id authorized by name",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": true,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "n1",
|
||||
ResourceName: "r1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
Partition{
|
||||
Namespace: "n1",
|
||||
Names: sets.NewString("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "by namespaced id authorized by name",
|
||||
apiOp: &types.APIRequest{
|
||||
Namespace: "n1",
|
||||
},
|
||||
id: "r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": true,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "n1",
|
||||
ResourceName: "r1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
Partition{
|
||||
Namespace: "n1",
|
||||
Names: sets.NewString("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "by id authorized by namespace",
|
||||
apiOp: &types.APIRequest{
|
||||
Namespace: "n1",
|
||||
},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": true,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "n1",
|
||||
ResourceName: "*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
Partition{
|
||||
Namespace: "n1",
|
||||
Names: sets.NewString("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "by id ignores unrequested resources",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": true,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "n1",
|
||||
ResourceName: "r1",
|
||||
},
|
||||
accesscontrol.Access{
|
||||
Namespace: "n1",
|
||||
ResourceName: "r2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
Partition{
|
||||
Namespace: "n1",
|
||||
Names: sets.NewString("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
// Note: this is deprecated fallback behavior. When we remove the behavior,
|
||||
// rewrite this test to expect an error instead.
|
||||
{
|
||||
name: "by id prefers id embedded namespace",
|
||||
apiOp: &types.APIRequest{
|
||||
Namespace: "n2",
|
||||
},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": true,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "n1",
|
||||
ResourceName: "r1",
|
||||
},
|
||||
accesscontrol.Access{
|
||||
Namespace: "n2",
|
||||
ResourceName: "r2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
Partition{
|
||||
Namespace: "n1",
|
||||
Names: sets.NewString("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cluster scoped id unauthorized",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "c1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
},
|
||||
},
|
||||
wantPartitions: nil,
|
||||
},
|
||||
{
|
||||
name: "cluster scoped id authorized by name",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "c1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": false,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "*",
|
||||
ResourceName: "c1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
Partition{
|
||||
Namespace: "",
|
||||
Names: sets.NewString("c1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cluster scoped id authorized globally",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "c1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": false,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "*",
|
||||
ResourceName: "*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
Partition{
|
||||
Namespace: "",
|
||||
Names: sets.NewString("c1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cluster scoped id ignores unrequested resources",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "c1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": false,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "*",
|
||||
ResourceName: "c1",
|
||||
},
|
||||
accesscontrol.Access{
|
||||
Namespace: "*",
|
||||
ResourceName: "c2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
Partition{
|
||||
Namespace: "",
|
||||
Names: sets.NewString("c1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
partitioner := rbacPartitioner{}
|
||||
verb := "watch"
|
||||
gotPartitions, gotErr := partitioner.All(test.apiOp, test.schema, verb, test.id)
|
||||
assert.Nil(t, gotErr)
|
||||
assert.Equal(t, test.wantPartitions, gotPartitions)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import (
|
||||
"github.com/rancher/steve/pkg/accesscontrol"
|
||||
"github.com/rancher/steve/pkg/attributes"
|
||||
"github.com/rancher/wrangler/v3/pkg/kv"
|
||||
"github.com/sirupsen/logrus"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
@ -46,17 +47,10 @@ func (p *rbacPartitioner) All(apiOp *types.APIRequest, schema *types.APISchema,
|
||||
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 := generatePartitionsByID(apiOp, schema, verb, id)
|
||||
return partitions, nil
|
||||
}
|
||||
partitions, passthrough := isPassthrough(apiOp, schema, verb)
|
||||
partitions, passthrough := generateAggregatePartitions(apiOp, schema, verb)
|
||||
if passthrough {
|
||||
return passthroughPartitions, nil
|
||||
}
|
||||
@ -74,15 +68,92 @@ 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) {
|
||||
// generatePartitionsById determines whether a requester can access a particular resource
|
||||
// and if so, returns the corresponding partitions
|
||||
func generatePartitionsByID(apiOp *types.APIRequest, schema *types.APISchema, verb string, id string) []partition.Partition {
|
||||
accessListByVerb, _ := attributes.Access(schema).(accesscontrol.AccessListByVerb)
|
||||
resources := accessListByVerb.Granted(verb)
|
||||
|
||||
idNamespace, name := kv.RSplit(id, "/")
|
||||
apiNamespace := apiOp.Namespace
|
||||
effectiveNamespace := idNamespace
|
||||
|
||||
// If a non-empty namespace was provided, be sure to select that for filtering and permissions checks
|
||||
if idNamespace == "" && apiNamespace != "" {
|
||||
effectiveNamespace = apiNamespace
|
||||
}
|
||||
|
||||
// The external API is flexible, and permits specifying a namespace as a separate key or embedded
|
||||
// within the ID of the object. Both of these cases should be valid:
|
||||
// {"namespace": "n1", "id": "r1"}
|
||||
// {"id": "n1/r1"}
|
||||
// however, the following conflicting request is not valid, but was previously accepted:
|
||||
// {"namespace": "n1", "id": "n2/r1"}
|
||||
// To avoid breaking UI plugins that may inadvertently rely on the feature, we issue a deprecation
|
||||
// warning for now. We still need to pick one of the namespaces for permission verification purposes.
|
||||
if idNamespace != "" && apiNamespace != "" && idNamespace != apiNamespace {
|
||||
logrus.Warningf("DEPRECATION: Conflicting namespaces '%v' and '%v' requested. "+
|
||||
"Selecting '%v' as the effective namespace. Future steve versions will reject this request.",
|
||||
idNamespace, apiNamespace, effectiveNamespace)
|
||||
}
|
||||
|
||||
if accessListByVerb.All(verb) {
|
||||
return []partition.Partition{
|
||||
{
|
||||
Namespace: effectiveNamespace,
|
||||
All: false,
|
||||
Passthrough: false,
|
||||
Names: sets.New(name),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if effectiveNamespace != "" {
|
||||
if resources[effectiveNamespace].All {
|
||||
return []partition.Partition{
|
||||
{
|
||||
Namespace: effectiveNamespace,
|
||||
All: false,
|
||||
Passthrough: false,
|
||||
Names: sets.New(name),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For cluster-scoped resources, we will have parsed a "" out
|
||||
// of the ID field from RSplit, but accessListByVerb specifies "*" for
|
||||
// the nameset, so correct that here
|
||||
resourceNamespace := effectiveNamespace
|
||||
if resourceNamespace == "" {
|
||||
resourceNamespace = accesscontrol.All
|
||||
}
|
||||
|
||||
nameset, ok := resources[resourceNamespace]
|
||||
if ok && nameset.Names.Has(name) {
|
||||
return []partition.Partition{
|
||||
{
|
||||
Namespace: effectiveNamespace,
|
||||
All: false,
|
||||
Passthrough: false,
|
||||
Names: sets.New(name),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateAggregatePartitions 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 generateAggregatePartitions(apiOp *types.APIRequest, schema *types.APISchema, verb string) ([]partition.Partition, bool) {
|
||||
accessListByVerb, _ := attributes.Access(schema).(accesscontrol.AccessListByVerb)
|
||||
resources := accessListByVerb.Granted(verb)
|
||||
|
||||
if accessListByVerb.All(verb) {
|
||||
return nil, true
|
||||
}
|
||||
|
||||
resources := accessListByVerb.Granted(verb)
|
||||
if apiOp.Namespace != "" {
|
||||
if resources[apiOp.Namespace].All {
|
||||
return nil, true
|
||||
|
@ -13,7 +13,7 @@ import (
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
)
|
||||
|
||||
func TestAll(t *testing.T) {
|
||||
func TestVerbList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiOp *types.APIRequest
|
||||
@ -225,7 +225,7 @@ func TestAll(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "by id",
|
||||
name: "by id fully unauthorized",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
@ -233,6 +233,72 @@ func TestAll(t *testing.T) {
|
||||
ID: "foo",
|
||||
},
|
||||
},
|
||||
wantPartitions: nil,
|
||||
},
|
||||
{
|
||||
name: "by id missing namespace",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": true,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"list": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "n2",
|
||||
ResourceName: "r2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: nil,
|
||||
},
|
||||
{
|
||||
name: "by id missing resource",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
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: "r2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: nil,
|
||||
},
|
||||
{
|
||||
name: "by id authorized by name",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
{
|
||||
Namespace: "n1",
|
||||
@ -240,6 +306,228 @@ func TestAll(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "by id authorized by namespace",
|
||||
apiOp: &types.APIRequest{
|
||||
Namespace: "n1",
|
||||
},
|
||||
id: "n1/r1",
|
||||
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: "n1",
|
||||
All: false,
|
||||
Names: sets.New[string]("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "by namespaced id authorized by name",
|
||||
apiOp: &types.APIRequest{
|
||||
Namespace: "n1",
|
||||
},
|
||||
id: "r1",
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
{
|
||||
Namespace: "n1",
|
||||
All: false,
|
||||
Names: sets.New[string]("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "by id ignores unrequested resources",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
{
|
||||
Namespace: "n1",
|
||||
Names: sets.New[string]("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
// Note: this is deprecated fallback behavior. When we remove the behavior,
|
||||
// rewrite this test to expect an error instead.
|
||||
{
|
||||
name: "by id prefers id embedded namespace",
|
||||
apiOp: &types.APIRequest{
|
||||
Namespace: "n2",
|
||||
},
|
||||
id: "n1/r1",
|
||||
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: "r2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
{
|
||||
Namespace: "n1",
|
||||
Names: sets.New[string]("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cluster scoped id unauthorized",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "c1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
},
|
||||
},
|
||||
wantPartitions: nil,
|
||||
},
|
||||
{
|
||||
name: "cluster scoped id authorized by name",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "c1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": false,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"list": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "*",
|
||||
ResourceName: "c1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
{
|
||||
Namespace: "",
|
||||
Names: sets.New[string]("c1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cluster scoped id authorized globally",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "c1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": false,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"list": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "*",
|
||||
ResourceName: "*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
{
|
||||
Namespace: "",
|
||||
Names: sets.New[string]("c1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cluster scoped id ignores unrequested resources",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "c1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": false,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"list": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "*",
|
||||
ResourceName: "c1",
|
||||
},
|
||||
accesscontrol.Access{
|
||||
Namespace: "*",
|
||||
ResourceName: "c2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
{
|
||||
Namespace: "",
|
||||
Names: sets.New[string]("c1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@ -253,6 +541,330 @@ func TestAll(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerbWatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiOp *types.APIRequest
|
||||
id string
|
||||
schema *types.APISchema
|
||||
wantPartitions []partition.Partition
|
||||
}{
|
||||
{
|
||||
name: "by id fully unauthorized",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
},
|
||||
},
|
||||
wantPartitions: nil,
|
||||
},
|
||||
{
|
||||
name: "by id missing namespace",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": true,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "n2",
|
||||
ResourceName: "r2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: nil,
|
||||
},
|
||||
{
|
||||
name: "by id missing resource",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": true,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "n1",
|
||||
ResourceName: "r2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: nil,
|
||||
},
|
||||
{
|
||||
name: "by id authorized by name",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": true,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "n1",
|
||||
ResourceName: "r1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
{
|
||||
Namespace: "n1",
|
||||
Names: sets.New[string]("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "by id authorized by namespace",
|
||||
apiOp: &types.APIRequest{
|
||||
Namespace: "n1",
|
||||
},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": true,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "n1",
|
||||
ResourceName: "*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
{
|
||||
Namespace: "n1",
|
||||
All: false,
|
||||
Names: sets.New[string]("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "by namespaced id authorized by name",
|
||||
apiOp: &types.APIRequest{
|
||||
Namespace: "n1",
|
||||
},
|
||||
id: "r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": true,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "n1",
|
||||
ResourceName: "r1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
{
|
||||
Namespace: "n1",
|
||||
All: false,
|
||||
Names: sets.New[string]("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "by id ignores unrequested resources",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": true,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "n1",
|
||||
ResourceName: "r1",
|
||||
},
|
||||
accesscontrol.Access{
|
||||
Namespace: "n1",
|
||||
ResourceName: "r2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
{
|
||||
Namespace: "n1",
|
||||
Names: sets.New[string]("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
// Note: this is deprecated fallback behavior. When we remove the behavior,
|
||||
// rewrite this test to expect an error instead.
|
||||
{
|
||||
name: "by id prefers id embedded namespace",
|
||||
apiOp: &types.APIRequest{
|
||||
Namespace: "n2",
|
||||
},
|
||||
id: "n1/r1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": true,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "n1",
|
||||
ResourceName: "r1",
|
||||
},
|
||||
accesscontrol.Access{
|
||||
Namespace: "n2",
|
||||
ResourceName: "r2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
{
|
||||
Namespace: "n1",
|
||||
Names: sets.New[string]("r1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cluster scoped id unauthorized",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "c1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
},
|
||||
},
|
||||
wantPartitions: nil,
|
||||
},
|
||||
{
|
||||
name: "cluster scoped id authorized by name",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "c1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": false,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "*",
|
||||
ResourceName: "c1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
{
|
||||
Namespace: "",
|
||||
Names: sets.New[string]("c1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cluster scoped id authorized globally",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "c1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": false,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "*",
|
||||
ResourceName: "*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
{
|
||||
Namespace: "",
|
||||
Names: sets.New[string]("c1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cluster scoped id ignores unrequested resources",
|
||||
apiOp: &types.APIRequest{},
|
||||
id: "c1",
|
||||
schema: &types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
ID: "foo",
|
||||
Attributes: map[string]interface{}{
|
||||
"namespaced": false,
|
||||
"access": accesscontrol.AccessListByVerb{
|
||||
"watch": accesscontrol.AccessList{
|
||||
accesscontrol.Access{
|
||||
Namespace: "*",
|
||||
ResourceName: "c1",
|
||||
},
|
||||
accesscontrol.Access{
|
||||
Namespace: "*",
|
||||
ResourceName: "c2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPartitions: []partition.Partition{
|
||||
{
|
||||
Namespace: "",
|
||||
Names: sets.New[string]("c1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
partitioner := rbacPartitioner{}
|
||||
verb := "watch"
|
||||
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{
|
||||
|
@ -24,8 +24,9 @@ type SchemaColumnSetter interface {
|
||||
|
||||
// Store implements types.proxyStore for partitions.
|
||||
type Store struct {
|
||||
Partitioner Partitioner
|
||||
asl accesscontrol.AccessSetLookup
|
||||
Partitioner Partitioner
|
||||
asl accesscontrol.AccessSetLookup
|
||||
sqlReservedFields map[string]bool
|
||||
}
|
||||
|
||||
// NewStore creates a types.proxyStore implementation with a partitioner
|
||||
@ -36,6 +37,14 @@ func NewStore(store UnstructuredStore, asl accesscontrol.AccessSetLookup) *Store
|
||||
},
|
||||
asl: asl,
|
||||
}
|
||||
sqlReservedFields := map[string]bool{}
|
||||
for key, value := range types.ReservedFields {
|
||||
if key == "id" {
|
||||
continue
|
||||
}
|
||||
sqlReservedFields[key] = value
|
||||
}
|
||||
s.sqlReservedFields = sqlReservedFields
|
||||
|
||||
return s
|
||||
}
|
||||
@ -48,7 +57,7 @@ func (s *Store) Delete(apiOp *types.APIRequest, schema *types.APISchema, id stri
|
||||
if err != nil {
|
||||
return types.APIObject{}, err
|
||||
}
|
||||
return partition.ToAPI(schema, obj, warnings), nil
|
||||
return partition.ToAPI(schema, obj, warnings, types.ReservedFields), nil
|
||||
}
|
||||
|
||||
// ByID looks up a single object by its ID.
|
||||
@ -59,7 +68,7 @@ func (s *Store) ByID(apiOp *types.APIRequest, schema *types.APISchema, id string
|
||||
if err != nil {
|
||||
return types.APIObject{}, err
|
||||
}
|
||||
return partition.ToAPI(schema, obj, warnings), nil
|
||||
return partition.ToAPI(schema, obj, warnings, types.ReservedFields), nil
|
||||
}
|
||||
|
||||
// List returns a list of objects across all applicable partitions.
|
||||
@ -85,7 +94,8 @@ func (s *Store) List(apiOp *types.APIRequest, schema *types.APISchema) (types.AP
|
||||
|
||||
for _, item := range list {
|
||||
item := item.DeepCopy()
|
||||
result.Objects = append(result.Objects, partition.ToAPI(schema, item, nil))
|
||||
// the sql cache automatically adds the ID through a transformFunc. Because of this, we have a different set of reserved fields for the SQL cache
|
||||
result.Objects = append(result.Objects, partition.ToAPI(schema, item, nil, s.sqlReservedFields))
|
||||
}
|
||||
|
||||
result.Revision = ""
|
||||
@ -101,7 +111,7 @@ func (s *Store) Create(apiOp *types.APIRequest, schema *types.APISchema, data ty
|
||||
if err != nil {
|
||||
return types.APIObject{}, err
|
||||
}
|
||||
return partition.ToAPI(schema, obj, warnings), nil
|
||||
return partition.ToAPI(schema, obj, warnings, types.ReservedFields), nil
|
||||
}
|
||||
|
||||
// Update updates a single object in the store.
|
||||
@ -112,7 +122,7 @@ func (s *Store) Update(apiOp *types.APIRequest, schema *types.APISchema, data ty
|
||||
if err != nil {
|
||||
return types.APIObject{}, err
|
||||
}
|
||||
return partition.ToAPI(schema, obj, warnings), nil
|
||||
return partition.ToAPI(schema, obj, warnings, types.ReservedFields), nil
|
||||
}
|
||||
|
||||
// Watch returns a channel of events for a list or resource.
|
||||
|
@ -1,5 +1,5 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/rancher/steve/pkg/stores/sqlproxy (interfaces: Cache,ClientGetter,CacheFactory,SchemaColumnSetter,RelationshipNotifier)
|
||||
// Source: github.com/rancher/steve/pkg/stores/sqlproxy (interfaces: Cache,ClientGetter,CacheFactory,SchemaColumnSetter,RelationshipNotifier,TransformBuilder)
|
||||
|
||||
// Package sqlproxy is a generated GoMock package.
|
||||
package sqlproxy
|
||||
@ -19,6 +19,7 @@ import (
|
||||
dynamic "k8s.io/client-go/dynamic"
|
||||
kubernetes "k8s.io/client-go/kubernetes"
|
||||
rest "k8s.io/client-go/rest"
|
||||
cache "k8s.io/client-go/tools/cache"
|
||||
)
|
||||
|
||||
// MockCache is a mock of Cache interface.
|
||||
@ -257,18 +258,18 @@ func (m *MockCacheFactory) EXPECT() *MockCacheFactoryMockRecorder {
|
||||
}
|
||||
|
||||
// CacheFor mocks base method.
|
||||
func (m *MockCacheFactory) CacheFor(arg0 [][]string, arg1 dynamic.ResourceInterface, arg2 schema.GroupVersionKind, arg3 bool) (factory.Cache, error) {
|
||||
func (m *MockCacheFactory) CacheFor(arg0 [][]string, arg1 cache.TransformFunc, arg2 dynamic.ResourceInterface, arg3 schema.GroupVersionKind, arg4, arg5 bool) (factory.Cache, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CacheFor", arg0, arg1, arg2, arg3)
|
||||
ret := m.ctrl.Call(m, "CacheFor", arg0, arg1, arg2, arg3, arg4, arg5)
|
||||
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 {
|
||||
func (mr *MockCacheFactoryMockRecorder) CacheFor(arg0, arg1, arg2, arg3, arg4, arg5 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)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CacheFor", reflect.TypeOf((*MockCacheFactory)(nil).CacheFor), arg0, arg1, arg2, arg3, arg4, arg5)
|
||||
}
|
||||
|
||||
// Reset mocks base method.
|
||||
@ -358,3 +359,40 @@ func (mr *MockRelationshipNotifierMockRecorder) OnInboundRelationshipChange(arg0
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnInboundRelationshipChange", reflect.TypeOf((*MockRelationshipNotifier)(nil).OnInboundRelationshipChange), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// MockTransformBuilder is a mock of TransformBuilder interface.
|
||||
type MockTransformBuilder struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockTransformBuilderMockRecorder
|
||||
}
|
||||
|
||||
// MockTransformBuilderMockRecorder is the mock recorder for MockTransformBuilder.
|
||||
type MockTransformBuilderMockRecorder struct {
|
||||
mock *MockTransformBuilder
|
||||
}
|
||||
|
||||
// NewMockTransformBuilder creates a new mock instance.
|
||||
func NewMockTransformBuilder(ctrl *gomock.Controller) *MockTransformBuilder {
|
||||
mock := &MockTransformBuilder{ctrl: ctrl}
|
||||
mock.recorder = &MockTransformBuilderMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockTransformBuilder) EXPECT() *MockTransformBuilderMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// GetTransformFunc mocks base method.
|
||||
func (m *MockTransformBuilder) GetTransformFunc(arg0 schema.GroupVersionKind) cache.TransformFunc {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetTransformFunc", arg0)
|
||||
ret0, _ := ret[0].(cache.TransformFunc)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// GetTransformFunc indicates an expected call of GetTransformFunc.
|
||||
func (mr *MockTransformBuilderMockRecorder) GetTransformFunc(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransformFunc", reflect.TypeOf((*MockTransformBuilder)(nil).GetTransformFunc), arg0)
|
||||
}
|
||||
|
@ -15,20 +15,6 @@ import (
|
||||
"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"
|
||||
@ -42,20 +28,130 @@ import (
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
"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/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/rancher/steve/pkg/attributes"
|
||||
controllerschema "github.com/rancher/steve/pkg/controllers/schema"
|
||||
"github.com/rancher/steve/pkg/resources/common"
|
||||
"github.com/rancher/steve/pkg/resources/virtual"
|
||||
virtualCommon "github.com/rancher/steve/pkg/resources/virtual/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"
|
||||
|
||||
"k8s.io/client-go/tools/cache"
|
||||
)
|
||||
|
||||
const watchTimeoutEnv = "CATTLE_WATCH_TIMEOUT_SECONDS"
|
||||
const (
|
||||
watchTimeoutEnv = "CATTLE_WATCH_TIMEOUT_SECONDS"
|
||||
errNamespaceRequired = "metadata.namespace or apiOp.namespace are required"
|
||||
errResourceVersionRequired = "metadata.resourceVersion is required for update"
|
||||
)
|
||||
|
||||
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`}},
|
||||
gvkKey("", "v1", "ConfigMap"): {
|
||||
{"metadata", "labels[harvesterhci.io/cloud-init-template]"}},
|
||||
gvkKey("", "v1", "Event"): {
|
||||
{"_type"},
|
||||
{"involvedObject", "kind"},
|
||||
{"message"},
|
||||
{"reason"},
|
||||
},
|
||||
gvkKey("", "v1", "Namespace"): {
|
||||
{"metadata", "labels[field.cattle.io/projectId]"}},
|
||||
gvkKey("", "v1", "Node"): {
|
||||
{"status", "nodeInfo", "kubeletVersion"},
|
||||
{"status", "nodeInfo", "operatingSystem"}},
|
||||
gvkKey("", "v1", "PersistentVolume"): {
|
||||
{"status", "reason"},
|
||||
{"spec", "persistentVolumeReclaimPolicy"},
|
||||
},
|
||||
gvkKey("", "v1", "PersistentVolumeClaim"): {
|
||||
{"spec", "volumeName"}},
|
||||
gvkKey("", "v1", "Pod"): {
|
||||
{"spec", "containers", "image"},
|
||||
{"spec", "nodeName"}},
|
||||
gvkKey("", "v1", "Service"): {
|
||||
{"spec", "clusterIP"},
|
||||
{"spec", "type"},
|
||||
},
|
||||
gvkKey("apps", "v1", "DaemonSet"): {
|
||||
{"metadata", "annotations[field.cattle.io/publicEndpoints]"},
|
||||
},
|
||||
gvkKey("apps", "v1", "Deployment"): {
|
||||
{"metadata", "annotations[field.cattle.io/publicEndpoints]"},
|
||||
},
|
||||
gvkKey("apps", "v1", "StatefulSet"): {
|
||||
{"metadata", "annotations[field.cattle.io/publicEndpoints]"},
|
||||
},
|
||||
gvkKey("autoscaling", "v2", "HorizontalPodAutoscaler"): {
|
||||
{"spec", "scaleTargetRef", "name"},
|
||||
{"spec", "minReplicas"},
|
||||
{"spec", "maxReplicas"},
|
||||
{"status", "currentReplicas"},
|
||||
},
|
||||
gvkKey("batch", "v1", "CronJob"): {
|
||||
{"metadata", "annotations[field.cattle.io/publicEndpoints]"},
|
||||
},
|
||||
gvkKey("batch", "v1", "Job"): {
|
||||
{"metadata", "annotations[field.cattle.io/publicEndpoints]"},
|
||||
},
|
||||
gvkKey("catalog.cattle.io", "v1", "App"): {
|
||||
{"spec", "chart", "metadata", "name"},
|
||||
},
|
||||
gvkKey("catalog.cattle.io", "v1", "ClusterRepo"): {
|
||||
{"metadata", "annotations[clusterrepo.cattle.io/hidden]"},
|
||||
{"spec", "gitBranch"},
|
||||
{"spec", "gitRepo"},
|
||||
},
|
||||
gvkKey("catalog.cattle.io", "v1", "Operation"): {
|
||||
{"status", "action"},
|
||||
{"status", "namespace"},
|
||||
{"status", "releaseName"},
|
||||
},
|
||||
gvkKey("cluster.x-k8s.io", "v1beta1", "Machine"): {
|
||||
{"spec", "clusterName"}},
|
||||
gvkKey("management.cattle.io", "v3", "Cluster"): {
|
||||
{"metadata", "labels[provider.cattle.io]"},
|
||||
{"spec", "internal"},
|
||||
{"spec", "displayName"},
|
||||
{"status", "provider"},
|
||||
},
|
||||
gvkKey("management.cattle.io", "v3", "Node"): {
|
||||
{"status", "nodeName"}},
|
||||
gvkKey("management.cattle.io", "v3", "NodePool"): {
|
||||
{"spec", "clusterName"}},
|
||||
gvkKey("management.cattle.io", "v3", "NodeTemplate"): {
|
||||
{"spec", "clusterName"}},
|
||||
gvkKey("networking.k8s.io", "v1", "Ingress"): {
|
||||
{"spec", "rules", "host"},
|
||||
{"spec", "ingressClassName"},
|
||||
},
|
||||
gvkKey("provisioning.cattle.io", "v1", "Cluster"): {
|
||||
{"metadata", "labels[provider.cattle.io]"},
|
||||
{"status", "clusterName"},
|
||||
{"status", "provider"},
|
||||
},
|
||||
gvkKey("storage.k8s.io", "v1", "StorageClass"): {
|
||||
{"provisioner"},
|
||||
{"metadata", "annotations[storageclass.kubernetes.io/is-default-class]"},
|
||||
},
|
||||
}
|
||||
commonIndexFields = [][]string{
|
||||
{`id`},
|
||||
{`metadata`, `state`, `name`},
|
||||
}
|
||||
baseNSSchema = types.APISchema{
|
||||
Schema: &schemas.Schema{
|
||||
@ -118,29 +214,35 @@ type RelationshipNotifier interface {
|
||||
OnInboundRelationshipChange(ctx context.Context, schema *types.APISchema, namespace string) <-chan *summary.Relationship
|
||||
}
|
||||
|
||||
type TransformBuilder interface {
|
||||
GetTransformFunc(gvk schema.GroupVersionKind) cache.TransformFunc
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
clientGetter ClientGetter
|
||||
notifier RelationshipNotifier
|
||||
cacheFactory CacheFactory
|
||||
cfInitializer CacheFactoryInitializer
|
||||
namespaceCache Cache
|
||||
lock sync.Mutex
|
||||
columnSetter SchemaColumnSetter
|
||||
clientGetter ClientGetter
|
||||
notifier RelationshipNotifier
|
||||
cacheFactory CacheFactory
|
||||
cfInitializer CacheFactoryInitializer
|
||||
namespaceCache Cache
|
||||
lock sync.Mutex
|
||||
columnSetter SchemaColumnSetter
|
||||
transformBuilder TransformBuilder
|
||||
}
|
||||
|
||||
type CacheFactoryInitializer func() (CacheFactory, error)
|
||||
|
||||
type CacheFactory interface {
|
||||
CacheFor(fields [][]string, client dynamic.ResourceInterface, gvk schema.GroupVersionKind, namespaced bool) (factory.Cache, error)
|
||||
CacheFor(fields [][]string, transform cache.TransformFunc, client dynamic.ResourceInterface, gvk schema.GroupVersionKind, namespaced bool, watchable 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) {
|
||||
func NewProxyStore(c SchemaColumnSetter, clientGetter ClientGetter, notifier RelationshipNotifier, scache virtualCommon.SummaryCache, factory CacheFactory) (*Store, error) {
|
||||
store := &Store{
|
||||
clientGetter: clientGetter,
|
||||
notifier: notifier,
|
||||
columnSetter: c,
|
||||
clientGetter: clientGetter,
|
||||
notifier: notifier,
|
||||
columnSetter: c,
|
||||
transformBuilder: virtual.NewTransformBuilder(scache),
|
||||
}
|
||||
|
||||
if factory == nil {
|
||||
@ -197,14 +299,18 @@ func (s *Store) initializeNamespaceCache() error {
|
||||
return err
|
||||
}
|
||||
|
||||
gvk := attributes.GVK(&nsSchema)
|
||||
// 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))...)
|
||||
fields = append(fields, getFieldForGVK(gvk)...)
|
||||
|
||||
// get the type-specifc transform func
|
||||
transformFunc := s.transformBuilder.GetTransformFunc(gvk)
|
||||
|
||||
// get the ns informer
|
||||
nsInformer, err := s.cacheFactory.CacheFor(fields, &tablelistconvert.Client{ResourceInterface: client}, attributes.GVK(&nsSchema), false)
|
||||
nsInformer, err := s.cacheFactory.CacheFor(fields, transformFunc, &tablelistconvert.Client{ResourceInterface: client}, attributes.GVK(&nsSchema), false, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -214,11 +320,17 @@ func (s *Store) initializeNamespaceCache() error {
|
||||
}
|
||||
|
||||
func getFieldForGVK(gvk schema.GroupVersionKind) [][]string {
|
||||
return typeSpecificIndexedFields[keyFromGVK(gvk)]
|
||||
fields := [][]string{}
|
||||
fields = append(fields, commonIndexFields...)
|
||||
typeFields := typeSpecificIndexedFields[gvkKey(gvk.Group, gvk.Version, gvk.Kind)]
|
||||
if typeFields != nil {
|
||||
fields = append(fields, typeFields...)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func keyFromGVK(gvk schema.GroupVersionKind) string {
|
||||
return gvk.Group + "_" + gvk.Version + "_" + gvk.Kind
|
||||
func gvkKey(group, version, kind string) string {
|
||||
return group + "_" + version + "_" + kind
|
||||
}
|
||||
|
||||
// getFieldsFromSchema converts object field names from types.APISchema's format into lasso's
|
||||
@ -483,20 +595,27 @@ func (s *Store) Create(apiOp *types.APIRequest, schema *types.APISchema, params
|
||||
}
|
||||
|
||||
name := types.Name(input)
|
||||
ns := types.Namespace(input)
|
||||
if name == "" && input.String("metadata", "generateName") == "" {
|
||||
input.SetNested(schema.ID[0:1]+"-", "metadata", "generatedName")
|
||||
namespace := types.Namespace(input)
|
||||
generateName := input.String("metadata", "generateName")
|
||||
|
||||
if name == "" && generateName == "" {
|
||||
input.SetNested(schema.ID[0:1]+"-", "metadata", "generateName")
|
||||
}
|
||||
if ns == "" && apiOp.Namespace != "" {
|
||||
ns = apiOp.Namespace
|
||||
input.SetNested(ns, "metadata", "namespace")
|
||||
|
||||
if attributes.Namespaced(schema) && namespace == "" {
|
||||
if apiOp.Namespace == "" {
|
||||
return nil, nil, apierror.NewAPIError(validation.InvalidBodyContent, errNamespaceRequired)
|
||||
}
|
||||
|
||||
namespace = apiOp.Namespace
|
||||
input.SetNested(namespace, "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))
|
||||
k8sClient, err := metricsStore.Wrap(s.clientGetter.TableClient(apiOp, schema, namespace, &buffer))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@ -563,9 +682,12 @@ func (s *Store) Update(apiOp *types.APIRequest, schema *types.APISchema, params
|
||||
|
||||
resourceVersion := input.String("metadata", "resourceVersion")
|
||||
if resourceVersion == "" {
|
||||
return nil, nil, fmt.Errorf("metadata.resourceVersion is required for update")
|
||||
return nil, nil, errors.New(errResourceVersionRequired)
|
||||
}
|
||||
|
||||
gvk := attributes.GVK(schema)
|
||||
input["apiVersion"], input["kind"] = gvk.ToAPIVersionAndKind()
|
||||
|
||||
opts := metav1.UpdateOptions{}
|
||||
if err := decodeParams(apiOp, &opts); err != nil {
|
||||
return nil, nil, err
|
||||
@ -623,10 +745,12 @@ func (s *Store) ListByPartitions(apiOp *types.APIRequest, schema *types.APISchem
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
gvk := attributes.GVK(schema)
|
||||
fields := getFieldsFromSchema(schema)
|
||||
fields = append(fields, getFieldForGVK(attributes.GVK(schema))...)
|
||||
fields = append(fields, getFieldForGVK(gvk)...)
|
||||
transformFunc := s.transformBuilder.GetTransformFunc(gvk)
|
||||
|
||||
inf, err := s.cacheFactory.CacheFor(fields, &tablelistconvert.Client{ResourceInterface: client}, attributes.GVK(schema), attributes.Namespaced(schema))
|
||||
inf, err := s.cacheFactory.CacheFor(fields, transformFunc, &tablelistconvert.Client{ResourceInterface: client}, attributes.GVK(schema), attributes.Namespaced(schema), controllerschema.IsListWatchable(schema))
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user