1
0
mirror of https://github.com/rancher/steve.git synced 2025-04-27 19:05:09 +00:00

Implement /ext in Steve for Imperative API (#287)

This implements the Imperative API that is served at /ext with Steve. The imperative API is compatible with Kubernetes' API server and will be used as an extension API server.
This commit is contained in:
Tom Lebreux 2024-10-11 15:19:27 -04:00 committed by GitHub
parent 57a25ffa82
commit 1f21e5e515
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 5343 additions and 4 deletions

View File

@ -25,6 +25,8 @@ jobs:
uses: golangci/golangci-lint-action@a4f60bb28d35aeee14e6880718e0c85ff1882e64 # v6.0.1
with:
version: v1.59.0
- name: Install env-test
run: go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest
- name: Build
run: make build-bin
- name: Test

View File

@ -795,3 +795,21 @@ Integration tests for the steve API are located among the [rancher integration
tests](ihttps://github.com/rancher/rancher/tree/release/v2.8/tests/v2/integration/steveapi).
See the documentation included there for running the tests and using them to
generate API documentation.
## Running Tests
Some of steve's tests make use of [envtest](https://book.kubebuilder.io/reference/envtest) to run. Envtest allows tests to run against a "fake" kubernetes server with little/no overhead.
To install the required `setup-envtest` binary, use the following command:
```bash
go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest
```
Before running the tests, you must run the following command to setup the fake server:
```bash
# note that this will use a new/latest version of k8s. Our CI will run against the version of k8s that corresponds to steve's
# current client-go version, as seen in scripts/test.sh
export KUBEBUILDER_ASSETS=$(setup-envtest use -p path)
```

23
go.mod
View File

@ -40,19 +40,27 @@ require (
k8s.io/klog v1.0.0
k8s.io/kube-aggregator v0.31.1
k8s.io/kube-openapi v0.0.0-20240411171206-dc4e619f62f3
sigs.k8s.io/controller-runtime v0.19.0
)
require (
github.com/NYTimes/gziphandler v1.1.1 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.4 // 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.9.0+incompatible // indirect
github.com/evanphx/json-patch/v5 v5.9.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
@ -61,13 +69,17 @@ require (
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.22.4 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/cel-go v0.20.1 // 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/go-grpc-prometheus v1.2.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/imdario/mergo v0.3.13 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // 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
@ -83,9 +95,15 @@ require (
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/cobra v1.8.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stoewer/go-strcase v1.2.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go.etcd.io/etcd/api/v3 v3.5.14 // indirect
go.etcd.io/etcd/client/pkg/v3 v3.5.14 // indirect
go.etcd.io/etcd/client/v3 v3.5.14 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
go.opentelemetry.io/otel v1.28.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect
@ -94,7 +112,10 @@ require (
go.opentelemetry.io/otel/sdk v1.28.0 // indirect
go.opentelemetry.io/otel/trace v1.28.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sys v0.23.0 // indirect
@ -107,9 +128,11 @@ require (
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/component-base v0.31.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kms v0.31.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

80
go.sum
View File

@ -11,10 +11,16 @@ github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbt
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY=
github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4=
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA=
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
@ -24,6 +30,10 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
@ -43,9 +53,13 @@ github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRr
github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls=
github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg=
github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
@ -57,6 +71,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0=
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
@ -70,11 +86,16 @@ github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogB
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
@ -86,6 +107,10 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
github.com/google/cel-go v0.20.1 h1:nDx9r8S3L4pE61eDdt8igGj8rf5kjYR3ILxWIpWNi84=
github.com/google/cel-go v0.20.1/go.mod h1:kWcIzTsPX0zmQ+H3TirHstLLf9ep5QTsZBN9u4dOYLg=
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@ -114,6 +139,12 @@ github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWS
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw=
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@ -124,6 +155,10 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO
github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
@ -209,15 +244,22 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js=
github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0=
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@ -225,17 +267,39 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk=
github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk=
github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA=
github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8=
github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI=
go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE=
go.etcd.io/etcd/api/v3 v3.5.14 h1:vHObSCxyB9zlF60w7qzAdTcGaglbJOpSj1Xj9+WGxq0=
go.etcd.io/etcd/api/v3 v3.5.14/go.mod h1:BmtWcRlQvwa1h3G2jvKYwIQy4PkHlDej5t7uLMUdJUU=
go.etcd.io/etcd/client/pkg/v3 v3.5.14 h1:SaNH6Y+rVEdxfpA2Jr5wkEvN6Zykme5+YnbCkxvuWxQ=
go.etcd.io/etcd/client/pkg/v3 v3.5.14/go.mod h1:8uMgAokyG1czCtIdsq+AGyYQMvpIKnSvPjFMunkgeZI=
go.etcd.io/etcd/client/v2 v2.305.13 h1:RWfV1SX5jTU0lbCvpVQe3iPQeAHETWdOTb6pxhd77C8=
go.etcd.io/etcd/client/v2 v2.305.13/go.mod h1:iQnL7fepbiomdXMb3om1rHq96htNNGv2sJkEcZGDRRg=
go.etcd.io/etcd/client/v3 v3.5.14 h1:CWfRs4FDaDoSz81giL7zPpZH2Z35tbOrAJkkjMqOupg=
go.etcd.io/etcd/client/v3 v3.5.14/go.mod h1:k3XfdV/VIHy/97rqWjoUzrj9tk7GgJGH9J8L4dNXmAk=
go.etcd.io/etcd/pkg/v3 v3.5.13 h1:st9bDWNsKkBNpP4PR1MvM/9NqUPfvYZx/YXegsYEH8M=
go.etcd.io/etcd/pkg/v3 v3.5.13/go.mod h1:N+4PLrp7agI/Viy+dUYpX7iRtSPvKq+w8Y14d1vX+m0=
go.etcd.io/etcd/raft/v3 v3.5.13 h1:7r/NKAOups1YnKcfro2RvGGo2PTuizF/xh26Z2CTAzA=
go.etcd.io/etcd/raft/v3 v3.5.13/go.mod h1:uUFibGLn2Ksm2URMxN1fICGhk8Wu96EfDQyuLhAcAmw=
go.etcd.io/etcd/server/v3 v3.5.13 h1:V6KG+yMfMSqWt+lGnhFpP5z5dRUj1BDRJ5k1fQ9DFok=
go.etcd.io/etcd/server/v3 v3.5.13/go.mod h1:K/8nbsGupHqmr5MkgaZpLlH1QdX1pcNQLAkODy44XcQ=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 h1:9G6E0TXzGFVfTnawRzrPl83iHOAV7L8NJiR8RSGYV1g=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
@ -256,6 +320,10 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@ -264,6 +332,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@ -339,6 +409,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@ -346,6 +418,8 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d h1:VBu5YqKPv6XiJ199exd8Br+Aetz+o08F+PLMnwJQHAY=
google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4=
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 h1:7whR9kGa5LUwFtpLm2ArCEejtnxlGeLbAyjFY8sGNFw=
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA=
@ -364,6 +438,8 @@ gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWM
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@ -398,6 +474,8 @@ k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=
k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kms v0.31.1 h1:cGLyV3cIwb0ovpP/jtyIe2mEuQ/MkbhmeBF2IYCA9Io=
k8s.io/kms v0.31.1/go.mod h1:OZKwl1fan3n3N5FFxnW5C4V3ygrah/3YXeJWS3O6+94=
k8s.io/kube-aggregator v0.31.1 h1:vrYBTTs3xMrpiEsmBjsLETZE9uuX67oQ8B3i1BFfMPw=
k8s.io/kube-aggregator v0.31.1/go.mod h1:+aW4NX50uneozN+BtoCxI4g7ND922p8Wy3tWKFDiWVk=
k8s.io/kube-openapi v0.0.0-20200121204235-bf4fb3bd569c/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E=
@ -436,6 +514,8 @@ sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 h1:2770sDpzrjjsA
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
sigs.k8s.io/cli-utils v0.37.2 h1:GOfKw5RV2HDQZDJlru5KkfLO1tbxqMoyn1IYUxqBpNg=
sigs.k8s.io/cli-utils v0.37.2/go.mod h1:V+IZZr4UoGj7gMJXklWBg6t5xbdThFBcpj4MrZuCYco=
sigs.k8s.io/controller-runtime v0.19.0 h1:nWVM7aq+Il2ABxwiCizrVDSlmDcshi9llbaFbC0ji/Q=
sigs.k8s.io/controller-runtime v0.19.0/go.mod h1:iRmWllt8IlaLjvTTDLhRBXIEtkCK6hwVBJJsYS9Ajf4=
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
sigs.k8s.io/structured-merge-diff/v3 v3.0.0-20200116222232-67a7b8c61874/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw=

277
pkg/ext/apiserver.go Normal file
View File

@ -0,0 +1,277 @@
package ext
import (
"context"
"fmt"
"net"
"net/http"
"strings"
"sync"
"k8s.io/apimachinery/pkg/api/meta"
metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/endpoints/openapi"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
genericoptions "k8s.io/apiserver/pkg/server/options"
utilversion "k8s.io/apiserver/pkg/util/version"
openapicommon "k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/validation/spec"
)
var (
schemeBuilder = runtime.NewSchemeBuilder(addKnownTypes, metainternalversion.AddToScheme)
AddToScheme = schemeBuilder.AddToScheme
)
func addKnownTypes(scheme *runtime.Scheme) error {
metav1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"})
return nil
}
type ExtensionAPIServerOptions struct {
// GetOpenAPIDefinitions is collection of all definitions. Required.
GetOpenAPIDefinitions openapicommon.GetOpenAPIDefinitions
OpenAPIDefinitionNameReplacements map[string]string
// Authenticator will be used to authenticate requests coming to the
// extension API server. Required.
Authenticator authenticator.Request
// Authorizer will be used to authorize requests based on the user,
// operation and resources. Required.
//
// Use [NewAccessSetAuthorizer] for an authorizer that uses Steve's access set.
Authorizer authorizer.Authorizer
// Listener is the TCP listener that is used to listen to the extension API server
// that is reached by the main kube-apiserver. Required.
Listener net.Listener
// EffectiveVersion determines which features and apis are supported
// by our custom API server.
//
// This is a new alpha feature from Kubernetes, the details can be
// found here: https://github.com/kubernetes/enhancements/tree/master/keps/sig-architecture/4330-compatibility-versions
//
// If nil, the default version is the version of the Kubernetes Go library
// compiled in the final binary.
EffectiveVersion utilversion.EffectiveVersion
}
// ExtensionAPIServer wraps a [genericapiserver.GenericAPIServer] to implement
// a Kubernetes extension API server.
//
// Use [NewExtensionAPIServer] to create an ExtensionAPIServer.
//
// Use [InstallStore] to add a new resource store onto an existing ExtensionAPIServer.
// Each resources will then be reachable via /apis/<group>/<version>/<resource> as
// defined by the Kubernetes API.
//
// When Run() is called, a separate HTTPS server is started. This server is meant
// for the main kube-apiserver to communicate with our extension API server. We
// can expect the following requests from the main kube-apiserver:
//
// <path> <user> <groups>
// /openapi/v2 system:aggregator [system:authenticated]
// /openapi/v3 system:aggregator [system:authenticated]
// /apis system:kube-aggregator [system:masters system:authenticated]
// /apis/ext.cattle.io/v1 system:kube-aggregator [system:masters system:authenticated]
type ExtensionAPIServer struct {
codecs serializer.CodecFactory
scheme *runtime.Scheme
genericAPIServer *genericapiserver.GenericAPIServer
apiGroups map[string]genericapiserver.APIGroupInfo
authorizer authorizer.Authorizer
handlerMu sync.RWMutex
handler http.Handler
}
type emptyAddresses struct{}
func (e emptyAddresses) ServerAddressByClientCIDRs(clientIP net.IP) []metav1.ServerAddressByClientCIDR {
return nil
}
func NewExtensionAPIServer(scheme *runtime.Scheme, codecs serializer.CodecFactory, opts ExtensionAPIServerOptions) (*ExtensionAPIServer, error) {
if opts.Authenticator == nil {
return nil, fmt.Errorf("authenticator must be provided")
}
if opts.Authorizer == nil {
return nil, fmt.Errorf("authorizer must be provided")
}
if opts.Listener == nil {
return nil, fmt.Errorf("listener must be provided")
}
recommendedOpts := genericoptions.NewRecommendedOptions("", codecs.LegacyCodec())
recommendedOpts.SecureServing.Listener = opts.Listener
resolver := &request.RequestInfoFactory{APIPrefixes: sets.NewString("apis", "api"), GrouplessAPIPrefixes: sets.NewString("api")}
config := genericapiserver.NewRecommendedConfig(codecs)
config.RequestInfoResolver = resolver
config.Authorization = genericapiserver.AuthorizationInfo{
Authorizer: opts.Authorizer,
}
// The default kube effective version ends up being the version of the
// library. (The value is hardcoded but it is kept up-to-date via some
// automation)
config.EffectiveVersion = utilversion.DefaultKubeEffectiveVersion()
if opts.EffectiveVersion != nil {
config.EffectiveVersion = opts.EffectiveVersion
}
// This feature is more of an optimization for clients that want to go directly to a custom API server
// instead of going through the main apiserver. We currently don't need to support this so we're leaving this
// empty.
config.DiscoveryAddresses = emptyAddresses{}
config.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig(opts.GetOpenAPIDefinitions, openapi.NewDefinitionNamer(scheme))
config.OpenAPIConfig.Info.Title = "Ext"
config.OpenAPIConfig.Info.Version = "0.1"
config.OpenAPIConfig.GetDefinitionName = getDefinitionName(scheme, opts.OpenAPIDefinitionNameReplacements)
// Must set to nil otherwise getDefinitionName won't be used for refs
// which will break kubectl explain
config.OpenAPIConfig.Definitions = nil
config.OpenAPIV3Config = genericapiserver.DefaultOpenAPIV3Config(opts.GetOpenAPIDefinitions, openapi.NewDefinitionNamer(scheme))
config.OpenAPIV3Config.Info.Title = "Ext"
config.OpenAPIV3Config.Info.Version = "0.1"
config.OpenAPIV3Config.GetDefinitionName = getDefinitionName(scheme, opts.OpenAPIDefinitionNameReplacements)
// Must set to nil otherwise getDefinitionName won't be used for refs
// which will break kubectl explain
config.OpenAPIV3Config.Definitions = nil
if err := recommendedOpts.SecureServing.ApplyTo(&config.SecureServing, &config.LoopbackClientConfig); err != nil {
return nil, fmt.Errorf("applyto secureserving: %w", err)
}
config.Authentication.Authenticator = opts.Authenticator
completedConfig := config.Complete()
genericServer, err := completedConfig.New("imperative-api", genericapiserver.NewEmptyDelegate())
if err != nil {
return nil, fmt.Errorf("new: %w", err)
}
extensionAPIServer := &ExtensionAPIServer{
codecs: codecs,
scheme: scheme,
genericAPIServer: genericServer,
apiGroups: make(map[string]genericapiserver.APIGroupInfo),
authorizer: opts.Authorizer,
}
return extensionAPIServer, nil
}
// Run prepares and runs the separate HTTPS server. It also configures the handler
// so that ServeHTTP can be used.
func (s *ExtensionAPIServer) Run(ctx context.Context) error {
for _, apiGroup := range s.apiGroups {
err := s.genericAPIServer.InstallAPIGroup(&apiGroup)
if err != nil {
return fmt.Errorf("installgroup: %w", err)
}
}
prepared := s.genericAPIServer.PrepareRun()
s.handlerMu.Lock()
s.handler = prepared.Handler
s.handlerMu.Unlock()
return nil
}
func (s *ExtensionAPIServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
s.handlerMu.RLock()
defer s.handlerMu.RUnlock()
s.handler.ServeHTTP(w, req)
}
// InstallStore installs a store on the given ExtensionAPIServer object.
//
// t and TList must be non-nil.
//
// Here's an example store for a Token and TokenList resource in the ext.cattle.io/v1 apiVersion:
//
// gvk := schema.GroupVersionKind{
// Group: "ext.cattle.io",
// Version: "v1",
// Kind: "Token",
// }
// InstallStore(s, &Token{}, &TokenList{}, "tokens", "token", gvk, store)
//
// Note: Not using a method on ExtensionAPIServer object due to Go generic limitations.
func InstallStore[T runtime.Object, TList runtime.Object](
s *ExtensionAPIServer,
t T,
tList TList,
resourceName string,
singularName string,
gvk schema.GroupVersionKind,
store Store[T, TList],
) error {
if !meta.IsListType(tList) {
return fmt.Errorf("tList (%T) is not a list type", tList)
}
apiGroup, ok := s.apiGroups[gvk.Group]
if !ok {
apiGroup = genericapiserver.NewDefaultAPIGroupInfo(gvk.Group, s.scheme, metav1.ParameterCodec, s.codecs)
}
_, ok = apiGroup.VersionedResourcesStorageMap[gvk.Version]
if !ok {
apiGroup.VersionedResourcesStorageMap[gvk.Version] = make(map[string]rest.Storage)
}
delegate := &delegate[T, TList]{
scheme: s.scheme,
t: t,
tList: tList,
singularName: singularName,
gvk: gvk,
gvr: schema.GroupVersionResource{
Group: gvk.Group,
Version: gvk.Version,
Resource: resourceName,
},
authorizer: s.authorizer,
store: store,
}
apiGroup.VersionedResourcesStorageMap[gvk.Version][resourceName] = delegate
s.apiGroups[gvk.Group] = apiGroup
return nil
}
func getDefinitionName(scheme *runtime.Scheme, replacements map[string]string) func(string) (string, spec.Extensions) {
return func(name string) (string, spec.Extensions) {
namer := openapi.NewDefinitionNamer(scheme)
definitionName, defGVK := namer.GetDefinitionName(name)
for key, val := range replacements {
if !strings.HasPrefix(definitionName, key) {
continue
}
updatedName := strings.ReplaceAll(definitionName, key, val)
return updatedName, defGVK
}
return definitionName, defGVK
}
}

View File

@ -0,0 +1,170 @@
package ext
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/server/options"
)
type authnTestStore struct {
*testStore
userCh chan user.Info
}
func (t *authnTestStore) List(ctx Context, opts *metav1.ListOptions) (*TestTypeList, error) {
t.userCh <- ctx.User
return &testTypeListFixture, nil
}
func (t *authnTestStore) getUser() (user.Info, bool) {
timer := time.NewTimer(time.Second * 5)
defer timer.Stop()
select {
case user := <-t.userCh:
return user, true
case <-timer.C:
return nil, false
}
}
func TestAuthenticationCustom(t *testing.T) {
scheme := runtime.NewScheme()
AddToScheme(scheme)
ln, _, err := options.CreateListener("", ":0", net.ListenConfig{})
require.NoError(t, err)
store := &authnTestStore{
testStore: &testStore{},
userCh: make(chan user.Info, 100),
}
extensionAPIServer, cleanup, err := setupExtensionAPIServer(t, scheme, &TestType{}, &TestTypeList{}, store, func(opts *ExtensionAPIServerOptions) {
opts.Listener = ln
opts.Authorizer = authorizer.AuthorizerFunc(authzAllowAll)
opts.Authenticator = authenticator.RequestFunc(func(req *http.Request) (*authenticator.Response, bool, error) {
user, ok := request.UserFrom(req.Context())
if !ok {
return nil, false, nil
}
if user.GetName() == "error" {
return nil, false, fmt.Errorf("fake error")
}
return &authenticator.Response{
User: user,
}, true, nil
})
}, nil)
require.NoError(t, err)
defer cleanup()
unauthorized := apierrors.NewUnauthorized("Unauthorized")
unauthorized.ErrStatus.Kind = "Status"
unauthorized.ErrStatus.APIVersion = "v1"
allPaths := []string{
"/",
"/apis",
"/apis/ext.cattle.io",
"/apis/ext.cattle.io/v1",
"/apis/ext.cattle.io/v1/testtypes",
"/apis/ext.cattle.io/v1/testtypes/foo",
"/openapi/v2",
"/openapi/v3",
"/openapi/v3/apis/ext.cattle.io/v1",
}
tests := []struct {
name string
user *user.DefaultInfo
paths []string
expectedStatusCode int
expectedStatus apierrors.APIStatus
expectedUser *user.DefaultInfo
}{
{
name: "authenticated request check user",
paths: []string{"/apis/ext.cattle.io/v1/testtypes"},
user: &user.DefaultInfo{Name: "my-user", Groups: []string{"my-group", "system:authenticated"}, Extra: map[string][]string{}},
expectedStatusCode: http.StatusOK,
expectedUser: &user.DefaultInfo{Name: "my-user", Groups: []string{"my-group", "system:authenticated"}, Extra: map[string][]string{}},
},
{
name: "authenticated request all paths",
user: &user.DefaultInfo{Name: "my-user", Groups: []string{"my-group", "system:authenticated"}, Extra: map[string][]string{}},
paths: allPaths,
expectedStatusCode: http.StatusOK,
},
{
name: "authenticated request to unknown endpoint",
user: &user.DefaultInfo{Name: "my-user", Groups: []string{"my-group", "system:authenticated"}, Extra: map[string][]string{}},
paths: []string{"/unknown"},
expectedStatusCode: http.StatusNotFound,
},
{
name: "unauthenticated request",
paths: append(allPaths, "/unknown"),
expectedStatusCode: http.StatusUnauthorized,
expectedStatus: unauthorized,
},
{
name: "authentication error",
user: &user.DefaultInfo{Name: "error"},
paths: append(allPaths, "/unknown"),
expectedStatusCode: http.StatusUnauthorized,
expectedStatus: unauthorized,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for _, path := range test.paths {
req := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
if test.user != nil {
ctx := request.WithUser(req.Context(), test.user)
req = req.WithContext(ctx)
}
extensionAPIServer.ServeHTTP(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
responseStatus := metav1.Status{}
json.Unmarshal(body, &responseStatus)
require.Equal(t, test.expectedStatusCode, resp.StatusCode, "for path "+path)
if test.expectedStatus != nil {
require.Equal(t, test.expectedStatus.Status(), responseStatus, "for path "+path)
}
if test.expectedUser != nil {
authUser, found := store.getUser()
require.True(t, found)
require.Equal(t, test.expectedUser, authUser)
}
}
})
}
}

View File

@ -0,0 +1,47 @@
package ext
import (
"context"
"github.com/rancher/steve/pkg/accesscontrol"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/authorization/authorizer"
)
var _ authorizer.Authorizer = (*AccessSetAuthorizer)(nil)
type AccessSetAuthorizer struct {
asl accesscontrol.AccessSetLookup
}
func NewAccessSetAuthorizer(asl accesscontrol.AccessSetLookup) *AccessSetAuthorizer {
return &AccessSetAuthorizer{
asl: asl,
}
}
// Authorize implements [authorizer.Authorizer].
func (a *AccessSetAuthorizer) Authorize(ctx context.Context, attrs authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
if !attrs.IsResourceRequest() {
// XXX: Implement
return authorizer.DecisionDeny, "AccessSetAuthorizer does not support nonResourceURLs requests", nil
}
verb := attrs.GetVerb()
namespace := attrs.GetNamespace()
name := attrs.GetName()
gr := schema.GroupResource{
Group: attrs.GetAPIGroup(),
Resource: attrs.GetResource(),
}
accessSet := a.asl.AccessFor(attrs.GetUser())
if accessSet.Grants(verb, gr, namespace, name) {
return authorizer.DecisionAllow, "", nil
}
// An empty string reason will still provide enough information such as:
//
// testtypes.ext.cattle.io is forbidden: User "unknown-user" cannot list resource "testtypes" in API group "ext.cattle.io" at the cluster scope
return authorizer.DecisionDeny, "", nil
}

View File

@ -0,0 +1,344 @@
package ext
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/rancher/lasso/pkg/controller"
"github.com/rancher/steve/pkg/accesscontrol"
wrbacv1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/rbac/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
rbacv1 "k8s.io/api/rbac/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
yamlutil "k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/server/options"
)
type authzTestStore struct {
*testStore
}
func (t *authzTestStore) Get(ctx Context, name string, opts *metav1.GetOptions) (*TestType, error) {
if name == "not-found" {
return nil, apierrors.NewNotFound(ctx.GroupVersionResource.GroupResource(), name)
}
return t.testStore.Get(ctx, name, opts)
}
func (t *authzTestStore) List(ctx Context, opts *metav1.ListOptions) (*TestTypeList, error) {
if ctx.User.GetName() == "read-only-error" {
decision, _, err := ctx.Authorizer.Authorize(ctx, authorizer.AttributesRecord{
User: ctx.User,
Verb: "customverb",
Resource: "testtypes",
ResourceRequest: true,
APIGroup: "ext.cattle.io",
})
if err != nil || decision != authorizer.DecisionAllow {
if err == nil {
err = fmt.Errorf("not allowed")
}
forbidden := apierrors.NewForbidden(ctx.GroupVersionResource.GroupResource(), "Forbidden", err)
forbidden.ErrStatus.Kind = "Status"
forbidden.ErrStatus.APIVersion = "v1"
return nil, forbidden
}
}
return &testTypeListFixture, nil
}
func (s *ExtensionAPIServerSuite) TestAuthorization() {
t := s.T()
scheme := runtime.NewScheme()
AddToScheme(scheme)
rbacv1.AddToScheme(scheme)
codecs := serializer.NewCodecFactory(scheme)
controllerFactory, err := controller.NewSharedControllerFactoryFromConfigWithOptions(s.restConfig, scheme, &controller.SharedControllerFactoryOptions{})
require.NoError(t, err)
rbacController := wrbacv1.New(controllerFactory)
accessStore := accesscontrol.NewAccessStore(s.ctx, false, rbacController)
authz := NewAccessSetAuthorizer(accessStore)
err = controllerFactory.Start(s.ctx, 2)
require.NoError(t, err)
ln, _, err := options.CreateListener("", ":0", net.ListenConfig{})
require.NoError(t, err)
store := &authzTestStore{
testStore: &testStore{},
}
extensionAPIServer, cleanup, err := setupExtensionAPIServer(t, scheme, &TestType{}, &TestTypeList{}, store, func(opts *ExtensionAPIServerOptions) {
opts.Listener = ln
opts.Authorizer = authz
opts.Authenticator = authenticator.RequestFunc(func(req *http.Request) (*authenticator.Response, bool, error) {
user, ok := request.UserFrom(req.Context())
if !ok {
return nil, false, nil
}
return &authenticator.Response{
User: user,
}, true, nil
})
}, nil)
require.NoError(t, err)
defer cleanup()
rbacBytes, err := os.ReadFile(filepath.Join("testdata", "rbac.yaml"))
require.NoError(t, err)
decoder := yamlutil.NewYAMLOrJSONDecoder(bytes.NewReader(rbacBytes), 4096)
for {
var rawObj runtime.RawExtension
if err = decoder.Decode(&rawObj); err != nil {
break
}
obj, _, err := codecs.UniversalDecoder(rbacv1.SchemeGroupVersion).Decode(rawObj.Raw, nil, nil)
require.NoError(t, err)
switch obj := obj.(type) {
case *rbacv1.ClusterRole:
_, err = s.client.RbacV1().ClusterRoles().Create(s.ctx, obj, metav1.CreateOptions{})
defer func(name string) {
s.client.RbacV1().ClusterRoles().Delete(s.ctx, obj.GetName(), metav1.DeleteOptions{})
}(obj.GetName())
case *rbacv1.ClusterRoleBinding:
_, err = s.client.RbacV1().ClusterRoleBindings().Create(s.ctx, obj, metav1.CreateOptions{})
defer func(name string) {
s.client.RbacV1().ClusterRoleBindings().Delete(s.ctx, obj.GetName(), metav1.DeleteOptions{})
}(obj.GetName())
}
require.NoError(t, err, "creating")
}
tests := []struct {
name string
user *user.DefaultInfo
createRequest func() *http.Request
expectedStatusCode int
expectedStatus apierrors.APIStatus
}{
{
name: "authorized get read-only not found",
user: &user.DefaultInfo{
Name: "read-only",
},
createRequest: func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes/not-found", nil)
},
expectedStatusCode: http.StatusNotFound,
},
{
name: "authorized get read-only",
user: &user.DefaultInfo{
Name: "read-only",
},
createRequest: func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes/foo", nil)
},
expectedStatusCode: http.StatusOK,
},
{
name: "authorized list read-only",
user: &user.DefaultInfo{
Name: "read-only",
},
createRequest: func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes", nil)
},
expectedStatusCode: http.StatusOK,
},
{
name: "unauthorized create from read-only",
user: &user.DefaultInfo{
Name: "read-only",
},
createRequest: func() *http.Request {
return httptest.NewRequest(http.MethodPost, "/apis/ext.cattle.io/v1/testtypes", nil)
},
expectedStatusCode: http.StatusForbidden,
},
{
name: "unauthorized update from read-only",
user: &user.DefaultInfo{
Name: "read-only",
},
createRequest: func() *http.Request {
return httptest.NewRequest(http.MethodPut, "/apis/ext.cattle.io/v1/testtypes/foo", nil)
},
expectedStatusCode: http.StatusForbidden,
},
{
name: "unauthorized delete from read-only",
user: &user.DefaultInfo{
Name: "read-only",
},
createRequest: func() *http.Request {
return httptest.NewRequest(http.MethodDelete, "/apis/ext.cattle.io/v1/testtypes/foo", nil)
},
expectedStatusCode: http.StatusForbidden,
},
{
name: "unauthorized create-on-update",
user: &user.DefaultInfo{
Name: "update-not-create",
},
createRequest: func() *http.Request {
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(&TestType{
TypeMeta: metav1.TypeMeta{
Kind: "TestType",
APIVersion: testTypeGV.String(),
},
ObjectMeta: metav1.ObjectMeta{
Name: "not-found",
},
})
return httptest.NewRequest(http.MethodPut, "/apis/ext.cattle.io/v1/testtypes/not-found", &buf)
},
expectedStatusCode: http.StatusForbidden,
},
{
name: "authorized read-only-error with custom store authorization",
user: &user.DefaultInfo{
Name: "read-only-error",
},
createRequest: func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes", nil)
},
expectedStatusCode: http.StatusForbidden,
},
{
name: "authorized get read-write not found",
user: &user.DefaultInfo{
Name: "read-write",
},
createRequest: func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes/not-found", nil)
},
expectedStatusCode: http.StatusNotFound,
},
{
name: "authorized get read-write",
user: &user.DefaultInfo{
Name: "read-write",
},
createRequest: func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes/foo", nil)
},
expectedStatusCode: http.StatusOK,
},
{
name: "authorized list read-write",
user: &user.DefaultInfo{
Name: "read-write",
},
createRequest: func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes", nil)
},
expectedStatusCode: http.StatusOK,
},
{
name: "authorized create from read-write",
user: &user.DefaultInfo{
Name: "read-write",
},
createRequest: func() *http.Request {
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(&TestType{
TypeMeta: metav1.TypeMeta{
Kind: "TestType",
APIVersion: testTypeGV.String(),
},
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
},
})
return httptest.NewRequest(http.MethodPost, "/apis/ext.cattle.io/v1/testtypes", &buf)
},
expectedStatusCode: http.StatusCreated,
},
{
name: "authorized update from read-write",
user: &user.DefaultInfo{
Name: "read-write",
},
createRequest: func() *http.Request {
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(&TestType{
TypeMeta: metav1.TypeMeta{
Kind: "TestType",
APIVersion: testTypeGV.String(),
},
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
},
})
return httptest.NewRequest(http.MethodPut, "/apis/ext.cattle.io/v1/testtypes/foo", &buf)
},
expectedStatusCode: http.StatusOK,
},
{
name: "unauthorized user",
user: &user.DefaultInfo{
Name: "unknown-user",
},
createRequest: func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes", nil)
},
expectedStatusCode: http.StatusForbidden,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req := test.createRequest()
w := httptest.NewRecorder()
if test.user != nil {
assert.EventuallyWithT(t, func(c *assert.CollectT) {
accessSet := accessStore.AccessFor(test.user)
assert.NotNil(c, accessSet)
}, time.Second*5, 100*time.Millisecond)
ctx := request.WithUser(req.Context(), test.user)
req = req.WithContext(ctx)
}
extensionAPIServer.ServeHTTP(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
responseStatus := metav1.Status{}
json.Unmarshal(body, &responseStatus)
require.Equal(t, test.expectedStatusCode, resp.StatusCode)
if test.expectedStatus != nil {
require.Equal(t, test.expectedStatus.Status(), responseStatus, "for request "+req.URL.String())
}
})
}
}

View File

@ -0,0 +1,50 @@
package ext
import (
"context"
"testing"
"github.com/stretchr/testify/suite"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/envtest"
)
type ExtensionAPIServerSuite struct {
suite.Suite
ctx context.Context
cancel context.CancelFunc
testEnv envtest.Environment
client *kubernetes.Clientset
restConfig *rest.Config
}
func (s *ExtensionAPIServerSuite) SetupSuite() {
var err error
apiServer := &envtest.APIServer{}
s.testEnv = envtest.Environment{
ControlPlane: envtest.ControlPlane{
APIServer: apiServer,
},
}
s.restConfig, err = s.testEnv.Start()
s.Require().NoError(err)
s.client, err = kubernetes.NewForConfig(s.restConfig)
s.Require().NoError(err)
s.ctx, s.cancel = context.WithCancel(context.Background())
}
func (s *ExtensionAPIServerSuite) TearDownSuite() {
s.cancel()
err := s.testEnv.Stop()
s.Require().NoError(err)
}
func TestExtensionAPIServerSuite(t *testing.T) {
suite.Run(t, new(ExtensionAPIServerSuite))
}

770
pkg/ext/apiserver_test.go Normal file
View File

@ -0,0 +1,770 @@
package ext
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
)
func authAsAdmin(req *http.Request) (*authenticator.Response, bool, error) {
return &authenticator.Response{
User: &user.DefaultInfo{
Name: "system:masters",
Groups: []string{"system:masters"},
},
}, true, nil
}
func authzAllowAll(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
return authorizer.DecisionAllow, "", nil
}
type mapStore struct {
items map[string]*TestType
events chan WatchEvent[*TestType]
}
func newMapStore() *mapStore {
return &mapStore{
items: make(map[string]*TestType),
events: make(chan WatchEvent[*TestType], 100),
}
}
func (t *mapStore) Create(ctx Context, obj *TestType, opts *metav1.CreateOptions) (*TestType, error) {
if _, found := t.items[obj.Name]; found {
return nil, apierrors.NewAlreadyExists(ctx.GroupVersionResource.GroupResource(), obj.Name)
}
t.items[obj.Name] = obj
t.events <- WatchEvent[*TestType]{
Event: watch.Added,
Object: obj,
}
return obj, nil
}
func (t *mapStore) Update(ctx Context, obj *TestType, opts *metav1.UpdateOptions) (*TestType, error) {
if _, found := t.items[obj.Name]; !found {
return nil, apierrors.NewNotFound(ctx.GroupVersionResource.GroupResource(), obj.Name)
}
obj.ManagedFields = []metav1.ManagedFieldsEntry{}
t.items[obj.Name] = obj
t.events <- WatchEvent[*TestType]{
Event: watch.Modified,
Object: obj,
}
return obj, nil
}
func (t *mapStore) Get(ctx Context, name string, opts *metav1.GetOptions) (*TestType, error) {
obj, found := t.items[name]
if !found {
return nil, apierrors.NewNotFound(ctx.GroupVersionResource.GroupResource(), name)
}
return obj, nil
}
func (t *mapStore) List(ctx Context, opts *metav1.ListOptions) (*TestTypeList, error) {
items := []TestType{}
for _, obj := range t.items {
items = append(items, *obj)
}
sort.Slice(items, func(i, j int) bool {
return items[i].Name > items[j].Name
})
list := &TestTypeList{
Items: items,
}
return list, nil
}
func (t *mapStore) Watch(ctx Context, opts *metav1.ListOptions) (<-chan WatchEvent[*TestType], error) {
return t.events, nil
}
func (t *mapStore) Delete(ctx Context, name string, opts *metav1.DeleteOptions) error {
obj, found := t.items[name]
if !found {
return apierrors.NewNotFound(ctx.GroupVersionResource.GroupResource(), name)
}
delete(t.items, name)
t.events <- WatchEvent[*TestType]{
Event: watch.Deleted,
Object: obj,
}
return nil
}
func TestStore(t *testing.T) {
scheme := runtime.NewScheme()
AddToScheme(scheme)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ln, err := (&net.ListenConfig{}).Listen(ctx, "tcp", ":0")
require.NoError(t, err)
store := newMapStore()
extensionAPIServer, cleanup, err := setupExtensionAPIServer(t, scheme, &TestType{}, &TestTypeList{}, store, func(opts *ExtensionAPIServerOptions) {
opts.Listener = ln
opts.Authorizer = authorizer.AuthorizerFunc(authzAllowAll)
opts.Authenticator = authenticator.RequestFunc(authAsAdmin)
}, nil)
require.NoError(t, err)
defer cleanup()
ts := httptest.NewServer(extensionAPIServer)
defer ts.Close()
recWatch, err := createRecordingWatcher(scheme, testTypeGV.WithResource("testtypes"), ts.URL)
require.NoError(t, err)
updatedObj := testTypeFixture.DeepCopy()
updatedObj.Annotations = map[string]string{
"foo": "bar",
}
updatedObjList := testTypeListFixture.DeepCopy()
updatedObjList.Items = []TestType{*updatedObj}
emptyList := testTypeListFixture.DeepCopy()
emptyList.Items = []TestType{}
createRequest := func(method string, path string, obj any) *http.Request {
var body io.Reader
if obj != nil {
raw, err := json.Marshal(obj)
require.NoError(t, err)
body = bytes.NewReader(raw)
}
return httptest.NewRequest(method, path, body)
}
tests := []struct {
name string
request *http.Request
newType any
expectedStatusCode int
expectedBody any
}{
{
name: "delete not existing",
request: createRequest(http.MethodDelete, "/apis/ext.cattle.io/v1/testtypes/foo", nil),
expectedStatusCode: http.StatusNotFound,
},
{
name: "get empty list",
request: createRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes", nil),
newType: &TestTypeList{},
expectedStatusCode: http.StatusOK,
expectedBody: emptyList,
},
{
name: "create testtype",
request: createRequest(http.MethodPost, "/apis/ext.cattle.io/v1/testtypes", testTypeFixture.DeepCopy()),
newType: &TestType{},
expectedStatusCode: http.StatusCreated,
expectedBody: &testTypeFixture,
},
{
name: "get non-empty list",
request: createRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes", nil),
newType: &TestTypeList{},
expectedStatusCode: http.StatusOK,
expectedBody: &testTypeListFixture,
},
{
name: "get specific object",
request: createRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes/foo", nil),
newType: &TestType{},
expectedStatusCode: http.StatusOK,
expectedBody: &testTypeFixture,
},
{
name: "update",
request: createRequest(http.MethodPut, "/apis/ext.cattle.io/v1/testtypes/foo", updatedObj.DeepCopy()),
newType: &TestType{},
expectedStatusCode: http.StatusOK,
expectedBody: updatedObj,
},
{
name: "get updated",
request: createRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes", nil),
newType: &TestTypeList{},
expectedStatusCode: http.StatusOK,
expectedBody: updatedObjList,
},
{
name: "delete",
request: createRequest(http.MethodDelete, "/apis/ext.cattle.io/v1/testtypes/foo", nil),
newType: &TestType{},
expectedStatusCode: http.StatusOK,
expectedBody: updatedObj,
},
{
name: "delete not found",
request: createRequest(http.MethodDelete, "/apis/ext.cattle.io/v1/testtypes/foo", nil),
expectedStatusCode: http.StatusNotFound,
},
{
name: "get not found",
request: createRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes/foo", nil),
expectedStatusCode: http.StatusNotFound,
},
{
name: "get empty list again",
request: createRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes", nil),
newType: &TestTypeList{},
expectedStatusCode: http.StatusOK,
expectedBody: emptyList,
},
{
name: "create via update",
newType: &TestType{},
request: createRequest(http.MethodPut, "/apis/ext.cattle.io/v1/testtypes/foo", testTypeFixture.DeepCopy()),
expectedStatusCode: http.StatusCreated,
expectedBody: &testTypeFixture,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req := test.request
w := httptest.NewRecorder()
extensionAPIServer.ServeHTTP(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
require.Equal(t, test.expectedStatusCode, resp.StatusCode)
if test.expectedBody != nil && test.newType != nil {
err = json.Unmarshal(body, test.newType)
require.NoError(t, err)
require.Equal(t, test.expectedBody, test.newType)
}
})
}
// Possibly flaky, find a better way to wait for all events
time.Sleep(1 * time.Second)
expectedEvents := []watch.Event{
{Type: watch.Added, Object: testTypeFixture.DeepCopy()},
{Type: watch.Modified, Object: updatedObj.DeepCopy()},
{Type: watch.Deleted, Object: updatedObj.DeepCopy()},
{Type: watch.Added, Object: testTypeFixture.DeepCopy()},
}
events := recWatch.getEvents()
require.Equal(t, len(expectedEvents), len(events))
for i, event := range events {
raw, err := json.Marshal(event.Object)
require.NoError(t, err)
obj := &TestType{}
err = json.Unmarshal(raw, obj)
require.NoError(t, err)
convertedEvent := watch.Event{
Type: event.Type,
Object: obj,
}
require.Equal(t, expectedEvents[i], convertedEvent)
}
}
var _ Store[*TestTypeOther, *TestTypeOtherList] = (*testStoreOther)(nil)
// This store is meant to be able to test many stores
type testStoreOther struct {
}
func (t *testStoreOther) Create(ctx Context, obj *TestTypeOther, opts *metav1.CreateOptions) (*TestTypeOther, error) {
return &testTypeOtherFixture, nil
}
func (t *testStoreOther) Update(ctx Context, obj *TestTypeOther, opts *metav1.UpdateOptions) (*TestTypeOther, error) {
return &testTypeOtherFixture, nil
}
func (t *testStoreOther) Get(ctx Context, name string, opts *metav1.GetOptions) (*TestTypeOther, error) {
return &testTypeOtherFixture, nil
}
func (t *testStoreOther) List(ctx Context, opts *metav1.ListOptions) (*TestTypeOtherList, error) {
return &testTypeOtherListFixture, nil
}
func (t *testStoreOther) Watch(ctx Context, opts *metav1.ListOptions) (<-chan WatchEvent[*TestTypeOther], error) {
return nil, nil
}
func (t *testStoreOther) Delete(ctx Context, name string, opts *metav1.DeleteOptions) error {
return nil
}
// The POC had a bug where multiple resources couldn't be installed so we're
// testing this here
func TestDiscoveryAndOpenAPI(t *testing.T) {
scheme := runtime.NewScheme()
AddToScheme(scheme)
differentVersion := schema.GroupVersion{
Group: "ext.cattle.io",
Version: "v2",
}
differentGroupVersion := schema.GroupVersion{
Group: "ext2.cattle.io",
Version: "v3",
}
scheme.AddKnownTypes(differentVersion, &TestType{}, &TestTypeList{})
scheme.AddKnownTypes(differentGroupVersion, &TestType{}, &TestTypeList{})
metav1.AddToGroupVersion(scheme, differentVersion)
metav1.AddToGroupVersion(scheme, differentGroupVersion)
ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", ":0")
require.NoError(t, err)
store := &testStore{}
extensionAPIServer, cleanup, err := setupExtensionAPIServer(t, scheme, &TestType{}, &TestTypeList{}, store, func(opts *ExtensionAPIServerOptions) {
opts.Listener = ln
opts.Authorizer = authorizer.AuthorizerFunc(authzAllowAll)
opts.Authenticator = authenticator.RequestFunc(authAsAdmin)
}, func(s *ExtensionAPIServer) error {
store := &testStoreOther{}
err := InstallStore(s, &TestTypeOther{}, &TestTypeOtherList{}, "testtypeothers", "testtypeother", testTypeGV.WithKind("TestTypeOther"), store)
if err != nil {
return err
}
err = InstallStore(s, &TestType{}, &TestTypeList{}, "testtypes", "testtype", differentVersion.WithKind("TestType"), &testStore{})
if err != nil {
return err
}
err = InstallStore(s, &TestType{}, &TestTypeList{}, "testtypes", "testtype", differentGroupVersion.WithKind("TestType"), &testStore{})
if err != nil {
return err
}
return nil
})
require.NoError(t, err)
defer cleanup()
tests := []struct {
path string
got any
expectedStatusCode int
expectedBody any
compareFunc func(*testing.T, any)
}{
{
path: "/apis",
got: &metav1.APIGroupList{},
expectedStatusCode: http.StatusOK,
// This is needed because the library loops over the apigroups
compareFunc: func(t *testing.T, gotObj any) {
apiGroupList, ok := gotObj.(*metav1.APIGroupList)
require.True(t, ok)
expectedAPIGroupList := &metav1.APIGroupList{
TypeMeta: metav1.TypeMeta{
Kind: "APIGroupList",
},
Groups: []metav1.APIGroup{
{
Name: "ext.cattle.io",
Versions: []metav1.GroupVersionForDiscovery{
{
GroupVersion: "ext.cattle.io/v2",
Version: "v2",
},
{
GroupVersion: "ext.cattle.io/v1",
Version: "v1",
},
},
PreferredVersion: metav1.GroupVersionForDiscovery{
GroupVersion: "ext.cattle.io/v2",
Version: "v2",
},
},
{
Name: "ext2.cattle.io",
Versions: []metav1.GroupVersionForDiscovery{
{
GroupVersion: "ext2.cattle.io/v3",
Version: "v3",
},
},
PreferredVersion: metav1.GroupVersionForDiscovery{
GroupVersion: "ext2.cattle.io/v3",
Version: "v3",
},
},
},
}
sortAPIGroupList(apiGroupList)
sortAPIGroupList(expectedAPIGroupList)
require.Equal(t, expectedAPIGroupList, apiGroupList)
},
},
{
path: "/apis/ext.cattle.io",
got: &metav1.APIGroup{},
expectedStatusCode: http.StatusOK,
expectedBody: &metav1.APIGroup{
TypeMeta: metav1.TypeMeta{
Kind: "APIGroup",
APIVersion: "v1",
},
Name: "ext.cattle.io",
Versions: []metav1.GroupVersionForDiscovery{
{
GroupVersion: "ext.cattle.io/v2",
Version: "v2",
},
{
GroupVersion: "ext.cattle.io/v1",
Version: "v1",
},
},
PreferredVersion: metav1.GroupVersionForDiscovery{
GroupVersion: "ext.cattle.io/v2",
Version: "v2",
},
},
},
{
path: "/apis/ext2.cattle.io",
got: &metav1.APIGroup{},
expectedStatusCode: http.StatusOK,
expectedBody: &metav1.APIGroup{
TypeMeta: metav1.TypeMeta{
Kind: "APIGroup",
APIVersion: "v1",
},
Name: "ext2.cattle.io",
Versions: []metav1.GroupVersionForDiscovery{
{
GroupVersion: "ext2.cattle.io/v3",
Version: "v3",
},
},
PreferredVersion: metav1.GroupVersionForDiscovery{
GroupVersion: "ext2.cattle.io/v3",
Version: "v3",
},
},
},
{
path: "/apis/ext.cattle.io/v1",
got: &metav1.APIResourceList{},
expectedStatusCode: http.StatusOK,
expectedBody: &metav1.APIResourceList{
TypeMeta: metav1.TypeMeta{
Kind: "APIResourceList",
APIVersion: "v1",
},
GroupVersion: "ext.cattle.io/v1",
APIResources: []metav1.APIResource{
{
Name: "testtypeothers",
SingularName: "testtypeother",
Namespaced: false,
Kind: "TestTypeOther",
Group: "ext.cattle.io",
Version: "v1",
Verbs: metav1.Verbs{
"create", "delete", "get", "list", "patch", "update", "watch",
},
},
{
Name: "testtypes",
SingularName: "testtype",
Namespaced: false,
Kind: "TestType",
Group: "ext.cattle.io",
Version: "v1",
Verbs: metav1.Verbs{
"create", "delete", "get", "list", "patch", "update", "watch",
},
},
},
},
},
{
path: "/apis/ext.cattle.io/v2",
got: &metav1.APIResourceList{},
expectedStatusCode: http.StatusOK,
expectedBody: &metav1.APIResourceList{
TypeMeta: metav1.TypeMeta{
Kind: "APIResourceList",
APIVersion: "v1",
},
GroupVersion: "ext.cattle.io/v2",
APIResources: []metav1.APIResource{
{
Name: "testtypes",
SingularName: "testtype",
Namespaced: false,
Kind: "TestType",
Group: "ext.cattle.io",
Version: "v2",
Verbs: metav1.Verbs{
"create", "delete", "get", "list", "patch", "update", "watch",
},
},
},
},
},
{
path: "/apis/ext2.cattle.io/v3",
got: &metav1.APIResourceList{},
expectedStatusCode: http.StatusOK,
expectedBody: &metav1.APIResourceList{
TypeMeta: metav1.TypeMeta{
Kind: "APIResourceList",
APIVersion: "v1",
},
GroupVersion: "ext2.cattle.io/v3",
APIResources: []metav1.APIResource{
{
Name: "testtypes",
SingularName: "testtype",
Namespaced: false,
Kind: "TestType",
Group: "ext2.cattle.io",
Version: "v3",
Verbs: metav1.Verbs{
"create", "delete", "get", "list", "patch", "update", "watch",
},
},
},
},
},
{
path: "/openapi/v2",
expectedStatusCode: http.StatusOK,
},
{
path: "/openapi/v3",
expectedStatusCode: http.StatusOK,
},
{
path: "/openapi/v3/apis",
expectedStatusCode: http.StatusOK,
},
{
path: "/openapi/v3/apis/ext.cattle.io",
expectedStatusCode: http.StatusOK,
},
{
path: "/openapi/v3/apis/ext.cattle.io/v1",
expectedStatusCode: http.StatusOK,
},
{
path: "/openapi/v3/apis/ext.cattle.io/v2",
expectedStatusCode: http.StatusOK,
},
{
path: "/openapi/v3/apis/ext2.cattle.io",
expectedStatusCode: http.StatusOK,
},
{
path: "/openapi/v3/apis/ext2.cattle.io/v3",
expectedStatusCode: http.StatusOK,
},
}
for _, test := range tests {
name := strings.ReplaceAll(test.path, "/", "_")
t.Run(name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, test.path, nil)
w := httptest.NewRecorder()
extensionAPIServer.ServeHTTP(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
require.Equal(t, test.expectedStatusCode, resp.StatusCode)
if test.expectedBody != nil && test.got != nil {
err = json.Unmarshal(body, test.got)
require.NoError(t, err)
require.Equal(t, test.expectedBody, test.got)
}
if test.got != nil && test.compareFunc != nil {
err = json.Unmarshal(body, test.got)
require.NoError(t, err)
test.compareFunc(t, test.got)
}
})
}
}
// Because the library has non-deterministic map iteration, changing the order of groups and versions
func sortAPIGroupList(list *metav1.APIGroupList) {
for _, group := range list.Groups {
sort.Slice(group.Versions, func(i, j int) bool {
return group.Versions[i].GroupVersion > group.Versions[j].GroupVersion
})
}
sort.Slice(list.Groups, func(i, j int) bool {
return list.Groups[i].Name > list.Groups[j].Name
})
}
func TestNoStore(t *testing.T) {
scheme := runtime.NewScheme()
codecs := serializer.NewCodecFactory(scheme)
ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", ":0")
require.NoError(t, err)
opts := ExtensionAPIServerOptions{
GetOpenAPIDefinitions: getOpenAPIDefinitions,
Listener: ln,
Authorizer: authorizer.AuthorizerFunc(authzAllowAll),
Authenticator: authenticator.RequestFunc(authAsAdmin),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
extensionAPIServer, err := NewExtensionAPIServer(scheme, codecs, opts)
require.NoError(t, err)
err = extensionAPIServer.Run(ctx)
require.NoError(t, err)
}
func setupExtensionAPIServer[
T runtime.Object,
TList runtime.Object,
](
t *testing.T,
scheme *runtime.Scheme,
objT T,
objTList TList,
store Store[T, TList],
optionSetter func(*ExtensionAPIServerOptions),
extensionAPIServerSetter func(*ExtensionAPIServer) error,
) (*ExtensionAPIServer, func(), error) {
addToSchemeTest(scheme)
codecs := serializer.NewCodecFactory(scheme)
opts := ExtensionAPIServerOptions{
GetOpenAPIDefinitions: getOpenAPIDefinitions,
OpenAPIDefinitionNameReplacements: map[string]string{
"com.github.rancher.steve.pkg.ext": "io.cattle.ext.v1",
},
}
if optionSetter != nil {
optionSetter(&opts)
}
extensionAPIServer, err := NewExtensionAPIServer(scheme, codecs, opts)
if err != nil {
return nil, func() {}, err
}
err = InstallStore(extensionAPIServer, objT, objTList, "testtypes", "testtype", testTypeGV.WithKind("TestType"), store)
if err != nil {
return nil, func() {}, fmt.Errorf("InstallStore: %w", err)
}
if extensionAPIServerSetter != nil {
err = extensionAPIServerSetter(extensionAPIServer)
if err != nil {
return nil, func() {}, fmt.Errorf("extensionAPIServerSetter: %w", err)
}
}
ctx, cancel := context.WithCancel(context.Background())
err = extensionAPIServer.Run(ctx)
require.NoError(t, err)
cleanup := func() {
cancel()
}
return extensionAPIServer, cleanup, nil
}
type recordingWatcher struct {
ch <-chan watch.Event
stop func()
}
func (w *recordingWatcher) getEvents() []watch.Event {
w.stop()
events := []watch.Event{}
for event := range w.ch {
events = append(events, event)
}
return events
}
func createRecordingWatcher(scheme *runtime.Scheme, gvr schema.GroupVersionResource, url string) (*recordingWatcher, error) {
codecs := serializer.NewCodecFactory(scheme)
gv := gvr.GroupVersion()
client, err := dynamic.NewForConfig(&rest.Config{
Host: url,
APIPath: "/apis",
ContentConfig: rest.ContentConfig{
NegotiatedSerializer: codecs,
GroupVersion: &gv,
},
})
if err != nil {
return nil, err
}
opts := metav1.ListOptions{
Watch: true,
}
myWatch, err := client.Resource(gvr).Watch(context.Background(), opts)
if err != nil {
return nil, err
}
// Should be plenty enough for most tests
ch := make(chan watch.Event, 100)
go func() {
for event := range myWatch.ResultChan() {
ch <- event
}
close(ch)
}()
return &recordingWatcher{
ch: ch,
stop: myWatch.Stop,
}, nil
}

351
pkg/ext/delegate.go Normal file
View File

@ -0,0 +1,351 @@
package ext
import (
"context"
"fmt"
"sync"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
)
// delegate is the bridge between k8s.io/apiserver's [rest.Storage] interface and
// our own Store interface we want developers to use
//
// It currently supports non-namespaced stores only because Store[T, TList] doesn't
// expose namespaces anywhere. When needed we'll add support to namespaced resources.
type delegate[T runtime.Object, TList runtime.Object] struct {
scheme *runtime.Scheme
// t is the resource of the delegate (eg: *Token) and must be non-nil.
t T
// tList is the resource list of the delegate (eg: *TokenList) and must be non-nil.
tList TList
gvk schema.GroupVersionKind
gvr schema.GroupVersionResource
singularName string
store Store[T, TList]
authorizer authorizer.Authorizer
}
// New implements [rest.Storage]
//
// It uses generics to create the resource and set its GVK.
func (s *delegate[T, TList]) New() runtime.Object {
t := s.t.DeepCopyObject()
t.GetObjectKind().SetGroupVersionKind(s.gvk)
return t
}
// Destroy cleans up its resources on shutdown.
// Destroy has to be implemented in thread-safe way and be prepared
// for being called more than once.
//
// It is NOT meant to delete resources from the backing storage. It is meant to
// stop clients, runners, etc that could be running for the store when the extension
// API server gracefully shutdowns/exits.
func (s *delegate[T, TList]) Destroy() {
}
// NewList implements [rest.Lister]
//
// It uses generics to create the resource and set its GVK.
func (s *delegate[T, TList]) NewList() runtime.Object {
tList := s.tList.DeepCopyObject()
tList.GetObjectKind().SetGroupVersionKind(s.gvk)
return tList
}
// List implements [rest.Lister]
func (s *delegate[T, TList]) List(parentCtx context.Context, internaloptions *metainternalversion.ListOptions) (runtime.Object, error) {
ctx, err := s.makeContext(parentCtx)
if err != nil {
return nil, err
}
options, err := s.convertListOptions(internaloptions)
if err != nil {
return nil, err
}
return s.store.List(ctx, options)
}
// ConvertToTable implements [rest.Lister]
//
// It converts an object or a list of objects to a table, which is used by kubectl
// (and Rancher UI) to display a table of the items.
//
// Currently, we use the default table convertor which will show two columns: Name and Created At.
func (s *delegate[T, TList]) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
defaultTableConverter := rest.NewDefaultTableConvertor(s.gvr.GroupResource())
return defaultTableConverter.ConvertToTable(ctx, object, tableOptions)
}
// Get implements [rest.Getter]
func (s *delegate[T, TList]) Get(parentCtx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
ctx, err := s.makeContext(parentCtx)
if err != nil {
return nil, err
}
return s.store.Get(ctx, name, options)
}
// Delete implements [rest.GracefulDeleter]
//
// deleteValidation is used to do some validation on the object before deleting
// it in the store. For example, running mutating/validating webhooks, though we're not using these yet.
func (s *delegate[T, TList]) Delete(parentCtx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
ctx, err := s.makeContext(parentCtx)
if err != nil {
return nil, false, err
}
oldObj, err := s.store.Get(ctx, name, &metav1.GetOptions{})
if err != nil {
return nil, false, err
}
if deleteValidation != nil {
if err = deleteValidation(ctx, oldObj); err != nil {
return nil, false, err
}
}
err = s.store.Delete(ctx, name, options)
return oldObj, true, err
}
// Create implements [rest.Creater]
//
// createValidation is used to do some validation on the object before creating
// it in the store. For example, running mutating/validating webhooks, though we're not using these yet.
//
//nolint:misspell
func (s *delegate[T, TList]) Create(parentCtx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
ctx, err := s.makeContext(parentCtx)
if err != nil {
return nil, err
}
if createValidation != nil {
err := createValidation(ctx, obj)
if err != nil {
return obj, err
}
}
tObj, ok := obj.(T)
if !ok {
return nil, fmt.Errorf("object was of type %T, not of expected type %T", obj, s.t)
}
return s.store.Create(ctx, tObj, options)
}
// Update implements [rest.Updater]
//
// createValidation is used to do some validation on the object before creating
// it in the store. For example, it will do an authorization check for "create"
// verb if the object needs to be created.
// See here for details: https://github.com/kubernetes/apiserver/blob/70ed6fdbea9eb37bd1d7558e90c20cfe888955e8/pkg/endpoints/handlers/update.go#L190-L201
// Another example is running mutating/validating webhooks, though we're not using these yet.
//
// updateValidation is used to do some validation on the object before updating it in the store.
// One example is running mutating/validating webhooks, though we're not using these yet.
func (s *delegate[T, TList]) Update(
parentCtx context.Context,
name string,
objInfo rest.UpdatedObjectInfo,
createValidation rest.ValidateObjectFunc,
updateValidation rest.ValidateObjectUpdateFunc,
forceAllowCreate bool,
options *metav1.UpdateOptions,
) (runtime.Object, bool, error) {
ctx, err := s.makeContext(parentCtx)
if err != nil {
return nil, false, err
}
oldObj, err := s.store.Get(ctx, name, &metav1.GetOptions{})
if err != nil {
if !apierrors.IsNotFound(err) {
return nil, false, err
}
obj, err := objInfo.UpdatedObject(ctx, nil)
if err != nil {
return nil, false, err
}
if err = createValidation(ctx, obj); err != nil {
return nil, false, err
}
tObj, ok := obj.(T)
if !ok {
return nil, false, fmt.Errorf("object was of type %T, not of expected type %T", obj, s.t)
}
newObj, err := s.store.Create(ctx, tObj, &metav1.CreateOptions{})
if err != nil {
return nil, false, err
}
return newObj, true, err
}
newObj, err := objInfo.UpdatedObject(ctx, oldObj)
if err != nil {
return nil, false, err
}
newT, ok := newObj.(T)
if !ok {
return nil, false, fmt.Errorf("object was of type %T, not of expected type %T", newObj, s.t)
}
if updateValidation != nil {
err = updateValidation(ctx, newT, oldObj)
if err != nil {
return nil, false, err
}
}
newT, err = s.store.Update(ctx, newT, options)
if err != nil {
return nil, false, err
}
return newT, false, nil
}
type watcher struct {
closedLock sync.RWMutex
closed bool
ch chan watch.Event
}
func (w *watcher) Stop() {
w.closedLock.Lock()
defer w.closedLock.Unlock()
if !w.closed {
close(w.ch)
w.closed = true
}
}
func (w *watcher) addEvent(event watch.Event) bool {
w.closedLock.RLock()
defer w.closedLock.RUnlock()
if w.closed {
return false
}
w.ch <- event
return true
}
func (w *watcher) ResultChan() <-chan watch.Event {
return w.ch
}
func (s *delegate[T, TList]) Watch(parentCtx context.Context, internaloptions *metainternalversion.ListOptions) (watch.Interface, error) {
ctx, err := s.makeContext(parentCtx)
if err != nil {
return nil, err
}
options, err := s.convertListOptions(internaloptions)
if err != nil {
return nil, err
}
w := &watcher{
ch: make(chan watch.Event),
}
go func() {
// Not much point continuing the watch if the store stopped its watch.
// Double stopping here is fine.
defer w.Stop()
// Closing eventCh is the responsibility of the store.Watch method
// to avoid the store panicking while trying to send to a close channel
eventCh, err := s.store.Watch(ctx, options)
if err != nil {
return
}
for event := range eventCh {
added := w.addEvent(watch.Event{
Type: event.Event,
Object: event.Object,
})
if !added {
break
}
}
}()
return w, nil
}
// GroupVersionKind implements rest.GroupVersionKind
//
// This is used to generate the data for the Discovery API
func (s *delegate[T, TList]) GroupVersionKind(_ schema.GroupVersion) schema.GroupVersionKind {
return s.gvk
}
// NamespaceScoped implements rest.Scoper
//
// The delegate is used for non-namespaced resources so it always returns false
func (s *delegate[T, TList]) NamespaceScoped() bool {
return false
}
// Kind implements rest.KindProvider
//
// XXX: Example where / how this is used
func (s *delegate[T, TList]) Kind() string {
return s.gvk.Kind
}
// GetSingularName implements rest.SingularNameProvider
//
// This is used by a variety of things such as kubectl to map singular name to
// resource name. (eg: token => tokens)
func (s *delegate[T, TList]) GetSingularName() string {
return s.singularName
}
func (s *delegate[T, TList]) makeContext(parentCtx context.Context) (Context, error) {
userInfo, ok := request.UserFrom(parentCtx)
if !ok {
return Context{}, fmt.Errorf("missing user info")
}
ctx := Context{
Context: parentCtx,
User: userInfo,
Authorizer: s.authorizer,
GroupVersionResource: s.gvr,
}
return ctx, nil
}
func (s *delegate[T, TList]) convertListOptions(options *metainternalversion.ListOptions) (*metav1.ListOptions, error) {
var out metav1.ListOptions
err := s.scheme.Convert(options, &out, nil)
if err != nil {
return nil, fmt.Errorf("convert list options: %w", err)
}
return &out, nil
}

2946
pkg/ext/fixtures_test.go Normal file

File diff suppressed because it is too large Load Diff

93
pkg/ext/store.go Normal file
View File

@ -0,0 +1,93 @@
package ext
import (
"context"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
)
// Context wraps a context.Context and adds a few fields that will be useful for
// each requests handled by a Store.
//
// It will allow us to add more such fields without breaking Store implementation.
type Context struct {
context.Context
// User is the user making the request
User user.Info
// Authorizer helps you determines if a user is authorized to perform
// actions to specific resources.
Authorizer authorizer.Authorizer
// GroupVersionResource is the GVR of the request.
// It makes it easy to create errors such as in:
// apierrors.NewNotFound(ctx.GroupVersionResource.GroupResource(), name)
GroupVersionResource schema.GroupVersionResource
}
// Store should provide all required operations to serve a given resource. A
// resource is defined by the resource itself (T) and a list type for the resource (TList).
// For example, Store[*Token, *TokenList] is a store that allows CRUD operations on *Token
// objects and allows listing tokens in a *TokenList object.
//
// Store does not define the backing storage for a resource. The storage is
// up to the implementer. For example, resources could be stored in another ETCD
// database, in a SQLite database, in another built-in resource such as Secrets.
// It is also possible to have no storage at all.
//
// Errors returned by the Store should use errors from k8s.io/apimachinery/pkg/api/errors. This
// will ensure that the right error will be returned to the clients (eg: kubectl, client-go) so
// they can react accordingly. For example, if an object is not found, store should
// return the following error:
//
// apierrors.NewNotFound(ctx.GroupVersionResource.GroupResource(), name)
//
// Stores should make use of the various metav1.*Options as best as possible.
// Those options are the same options coming from client-go or kubectl, generally
// meant to control the behavior of the stores. Note: We currently don't have
// field-manager enabled.
type Store[T runtime.Object, TList runtime.Object] interface {
// Create should store the resource to some backing storage.
//
// It can apply modifications as necessary before storing it. It must
// return a resource of the type of the store, but can
// create/update/delete arbitrary objects in Kubernetes without
// returning them to the user.
//
// It is called either when a request creates a resource, or when a
// request updates a resource that doesn't exist.
Create(ctx Context, obj T, opts *metav1.CreateOptions) (T, error)
// Update should overwrite a resource that is present in the backing storage.
//
// It can apply modifications as necessary before storing it. It must
// return a resource of the type of the store, but can
// create/update/delete arbitrary objects in Kubernetes without
// returning them to the user.
//
// It is called when a request updates a resource (eg: through a patch or update request)
Update(ctx Context, obj T, opts *metav1.UpdateOptions) (T, error)
// Get retrieves the resource with the given name from the backing storage.
//
// Get is called for the following requests:
// - get requests: The object must be returned.
// - update requests: The object is needed to apply a JSON patch and to make some validation on the change.
// - delete requests: The object is needed to make some validation on it.
Get(ctx Context, name string, opts *metav1.GetOptions) (T, error)
// List retrieves all resources matching the given ListOptions from the backing storage.
List(ctx Context, opts *metav1.ListOptions) (TList, error)
// Watch sends change events to a returned channel.
//
// The store is responsible for closing the channel.
Watch(ctx Context, opts *metav1.ListOptions) (<-chan WatchEvent[T], error)
// Delete deletes the resource of the given name from the backing storage.
Delete(ctx Context, name string, opts *metav1.DeleteOptions) error
}
type WatchEvent[T runtime.Object] struct {
Event watch.EventType
Object T
}

123
pkg/ext/testdata/rbac.yaml vendored Normal file
View File

@ -0,0 +1,123 @@
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: read-only
rules:
- apiGroups: ["ext.cattle.io"]
verbs: ["list", "get", "watch"]
resources: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: read-write
rules:
- apiGroups: ["ext.cattle.io"]
verbs: ["list", "get", "watch", "create", "update", "patch", "delete"]
resources: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: update-not-create
rules:
- apiGroups: ["ext.cattle.io"]
verbs: ["list", "get", "watch", "update"]
resources: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: all
rules:
- apiGroups: ["ext.cattle.io"]
verbs: ["*"]
resources: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: other
rules:
- apiGroups: ["management.cattle.io"]
verbs: ["*"]
resources: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: read-only
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: read-only
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: User
name: read-only
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: read-write
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: read-write
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: User
name: read-write
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: update-not-create
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: update-not-create
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: User
name: update-not-create
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: all
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: all
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: User
name: all
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: other
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: other
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: User
name: other
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: read-only-error
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: read-only
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: User
name: read-only-error

View File

@ -17,7 +17,7 @@ import (
)
func New(cfg *rest.Config, sf schema.Factory, authMiddleware auth.Middleware, next http.Handler,
routerFunc router.RouterFunc) (*apiserver.Server, http.Handler, error) {
routerFunc router.RouterFunc, extensionAPIServer http.Handler) (*apiserver.Server, http.Handler, error) {
var (
proxy http.Handler
err error
@ -46,6 +46,9 @@ func New(cfg *rest.Config, sf schema.Factory, authMiddleware auth.Middleware, ne
K8sProxy: w(proxy),
APIRoot: w(a.apiHandler(apiRoot)),
}
if extensionAPIServer != nil {
handlers.ExtensionAPIServer = w(extensionAPIServer)
}
if routerFunc == nil {
return a.server, router.Routes(handlers), nil
}

View File

@ -14,6 +14,9 @@ type Handlers struct {
APIRoot http.Handler
K8sProxy http.Handler
Next http.Handler
// ExtensionAPIServer serves under /ext. If nil, the default unknown path
// handler is served.
ExtensionAPIServer http.Handler
}
func Routes(h Handlers) http.Handler {
@ -25,6 +28,11 @@ func Routes(h Handlers) http.Handler {
m.Path("/").Handler(h.APIRoot).HeadersRegexp("Accept", ".*json.*")
m.Path("/{name:v1}").Handler(h.APIRoot)
if h.ExtensionAPIServer != nil {
m.Path("/ext").Handler(http.StripPrefix("/ext", h.ExtensionAPIServer))
m.PathPrefix("/ext/").Handler(http.StripPrefix("/ext", h.ExtensionAPIServer))
}
m.Path("/v1/{type}").Handler(h.K8sResource)
m.Path("/v1/{type}/{nameorns}").Queries("link", "{link}").Handler(h.K8sResource)
m.Path("/v1/{type}/{nameorns}").Queries("action", "{action}").Handler(h.K8sResource)

View File

@ -31,6 +31,16 @@ import (
var ErrConfigRequired = errors.New("rest config is required")
// ExtensionAPIServer will run an extension API server. The extension API server
// will be accessible from Steve at the /ext endpoint and will be compatible with
// the aggregate API server in Kubernetes.
type ExtensionAPIServer interface {
// The ExtensionAPIServer is served at /ext in Steve's mux
http.Handler
// Run configures the API server and make the HTTP handler available
Run(ctx context.Context)
}
type Server struct {
http.Handler
@ -44,6 +54,8 @@ type Server struct {
ClusterRegistry string
Version string
extensionAPIServer ExtensionAPIServer
authMiddleware auth.Middleware
controllers *Controllers
needControllerStart bool
@ -69,6 +81,14 @@ type Options struct {
ServerVersion string
// SQLCache enables the SQLite-based lasso caching mechanism
SQLCache bool
// ExtensionAPIServer enables an extension API server that will be served
// under /ext
// If nil, Steve's default http handler for unknown routes will be served.
//
// In most cases, you'll want to use [github.com/rancher/steve/pkg/ext.NewExtensionAPIServer]
// to create an ExtensionAPIServer.
ExtensionAPIServer ExtensionAPIServer
}
func New(ctx context.Context, restConfig *rest.Config, opts *Options) (*Server, error) {
@ -89,7 +109,8 @@ func New(ctx context.Context, restConfig *rest.Config, opts *Options) (*Server,
ClusterRegistry: opts.ClusterRegistry,
Version: opts.ServerVersion,
// SQLCache enables the SQLite-based lasso caching mechanism
SQLCache: opts.SQLCache,
SQLCache: opts.SQLCache,
extensionAPIServer: opts.ExtensionAPIServer,
}
if err := setup(ctx, server); err != nil {
@ -213,7 +234,7 @@ func setup(ctx context.Context, server *Server) error {
onSchemasHandler,
sf)
apiServer, handler, err := handler.New(server.RESTConfig, sf, server.authMiddleware, server.next, server.router)
apiServer, handler, err := handler.New(server.RESTConfig, sf, server.authMiddleware, server.next, server.router, server.extensionAPIServer)
if err != nil {
return err
}
@ -231,6 +252,9 @@ func (c *Server) start(ctx context.Context) error {
return err
}
}
if c.extensionAPIServer != nil {
c.extensionAPIServer.Run(ctx)
}
return nil
}

View File

@ -1,3 +1,13 @@
#!/bin/bash
go test ./...
if ! command -v setup-envtest; then
echo "setup-envtest is required for tests, but was not installed"
echo "see the 'Running Tests' section of the readme for install instructions"
exit 127
fi
minor=$(go list -m all | grep 'k8s.io/client-go' | cut -d ' ' -f 2 | cut -d '.' -f 2)
version="1.$minor.x"
export KUBEBUILDER_ASSETS=$(setup-envtest use -p path "$version")
go test ./...