mirror of
https://github.com/k3s-io/kubernetes.git
synced 2026-07-17 11:28:58 +00:00
openapi: filter cross-registered GVKs in per-GV v3 specs
This commit is contained in:
@@ -17,6 +17,8 @@ limitations under the License.
|
||||
package routes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
restful "github.com/emicklei/go-restful/v3"
|
||||
"k8s.io/klog/v2"
|
||||
|
||||
@@ -27,6 +29,7 @@ import (
|
||||
"k8s.io/kube-openapi/pkg/common/restfuladapter"
|
||||
"k8s.io/kube-openapi/pkg/handler"
|
||||
"k8s.io/kube-openapi/pkg/handler3"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
)
|
||||
|
||||
@@ -49,7 +52,9 @@ func (oa OpenAPI) InstallV2(c *restful.Container, mux *mux.PathRecorderMux) (*ha
|
||||
return openAPIVersionedService, spec
|
||||
}
|
||||
|
||||
// InstallV3 adds the static group/versions defined in the RegisteredWebServices to the OpenAPI v3 spec
|
||||
// InstallV3 adds the static group/versions defined in the RegisteredWebServices to the OpenAPI v3 spec.
|
||||
// This only covers built-in resources served via go-restful; CRDs and aggregated APIs publish
|
||||
// their OpenAPI v3 specs through separate code paths.
|
||||
func (oa OpenAPI) InstallV3(c *restful.Container, mux *mux.PathRecorderMux) *handler3.OpenAPIService {
|
||||
openAPIVersionedService := handler3.NewOpenAPIService()
|
||||
err := openAPIVersionedService.RegisterOpenAPIV3VersionedService("/openapi/v3", mux)
|
||||
@@ -69,9 +74,111 @@ func (oa OpenAPI) InstallV3(c *restful.Container, mux *mux.PathRecorderMux) *han
|
||||
spec, err := builder3.BuildOpenAPISpecFromRoutes(restfuladapter.AdaptWebServices(ws), oa.V3Config)
|
||||
if err != nil {
|
||||
klog.Errorf("Failed to build OpenAPI v3 for group %s, %q", gv, err)
|
||||
|
||||
continue
|
||||
}
|
||||
if group, version, ok := groupVersionFromPath(gv); ok {
|
||||
filterScopedGVKs(spec, group, version)
|
||||
}
|
||||
openAPIVersionedService.UpdateGroupVersion(gv, spec)
|
||||
}
|
||||
return openAPIVersionedService
|
||||
}
|
||||
|
||||
// groupVersionFromPath extracts the API group and version from a root path like "apis/apps/v1" or "api/v1".
|
||||
func groupVersionFromPath(path string) (group, version string, ok bool) {
|
||||
// "api/v1" → ("", "v1", true)
|
||||
// "apis/apps/v1" → ("apps", "v1", true)
|
||||
// "apis/networking.k8s.io/v1" → ("networking.k8s.io", "v1", true)
|
||||
parts := strings.SplitN(path, "/", 4)
|
||||
switch {
|
||||
case len(parts) < 2:
|
||||
return "", "", false
|
||||
case parts[0] == "api" && len(parts) == 2:
|
||||
return "", parts[1], true
|
||||
case parts[0] == "apis" && len(parts) == 3:
|
||||
return parts[1], parts[2], true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
// crossRegisteredKinds lists the kinds that AddToGroupVersion (in
|
||||
// k8s.io/apimachinery/pkg/apis/meta/v1) registers into every API group.
|
||||
// Only these types get their x-kubernetes-group-version-kind list filtered
|
||||
// in per-GV v3 specs.
|
||||
var crossRegisteredKinds = map[string]bool{
|
||||
"WatchEvent": true,
|
||||
"DeleteOptions": true,
|
||||
}
|
||||
|
||||
// filterScopedGVKs narrows x-kubernetes-group-version-kind on the meta types
|
||||
// that AddToGroupVersion cross-registers into every API group (WatchEvent and
|
||||
// DeleteOptions). Without filtering, each per-GV v3 spec carries ~60 GVKs for
|
||||
// these types. This filter keeps only the entry matching this spec's group/version
|
||||
// plus the canonical core/v1 entry.
|
||||
//
|
||||
// Only the meta types listed in crossRegisteredKinds are filtered. Other types
|
||||
// that are intentionally registered into specific groups (like autoscaling/v1
|
||||
// Scale into apps/v1 and core/v1) are left unchanged.
|
||||
//
|
||||
// This only affects built-in resource specs generated by InstallV3 above.
|
||||
// CRDs and aggregated APIs are not affected as they publish specs through separate paths.
|
||||
func filterScopedGVKs(s *spec3.OpenAPI, group, version string) {
|
||||
if s == nil || s.Components == nil {
|
||||
return
|
||||
}
|
||||
for _, schema := range s.Components.Schemas {
|
||||
if schema == nil {
|
||||
continue
|
||||
}
|
||||
ext, ok := schema.Extensions["x-kubernetes-group-version-kind"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
gvks, ok := ext.([]interface{})
|
||||
if !ok || len(gvks) <= 1 {
|
||||
continue
|
||||
}
|
||||
if !isCrossRegisteredKind(gvks) {
|
||||
continue
|
||||
}
|
||||
var filtered []interface{}
|
||||
for _, item := range gvks {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
g, ok := m["group"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v, ok := m["version"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if (g == group && v == version) || (g == "" && v == "v1") {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
if len(filtered) > 0 {
|
||||
schema.Extensions["x-kubernetes-group-version-kind"] = filtered
|
||||
} else {
|
||||
klog.Warningf("Unexpected: filtering x-kubernetes-group-version-kind for %s/%s produced no matches", group, version)
|
||||
delete(schema.Extensions, "x-kubernetes-group-version-kind")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isCrossRegisteredKind checks whether a GVK list represents one of the
|
||||
// meta types from crossRegisteredKinds by inspecting the first entry's kind.
|
||||
func isCrossRegisteredKind(gvks []interface{}) bool {
|
||||
if len(gvks) == 0 {
|
||||
return false
|
||||
}
|
||||
m, ok := gvks[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
kind, _ := m["kind"].(string)
|
||||
return crossRegisteredKinds[kind]
|
||||
}
|
||||
|
||||
177
staging/src/k8s.io/apiserver/pkg/server/routes/openapi_test.go
Normal file
177
staging/src/k8s.io/apiserver/pkg/server/routes/openapi_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
Copyright The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package routes
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
)
|
||||
|
||||
func TestGroupVersionFromPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
wantGroup string
|
||||
wantVersion string
|
||||
wantOK bool
|
||||
}{
|
||||
{"api/v1", "", "v1", true},
|
||||
{"apis/apps/v1", "apps", "v1", true},
|
||||
{"apis/networking.k8s.io/v1", "networking.k8s.io", "v1", true},
|
||||
{"apis/batch/v1", "batch", "v1", true},
|
||||
{"api", "", "", false},
|
||||
{"apis/apps", "", "", false},
|
||||
{"api/v1/extra", "", "", false},
|
||||
{"apis/apps/v1/extra", "", "", false},
|
||||
{"", "", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
group, version, ok := groupVersionFromPath(tt.path)
|
||||
if group != tt.wantGroup || version != tt.wantVersion || ok != tt.wantOK {
|
||||
t.Errorf("groupVersionFromPath(%q) = (%q, %q, %v), want (%q, %q, %v)", tt.path, group, version, ok, tt.wantGroup, tt.wantVersion, tt.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func gvk(group, version, kind string) map[string]interface{} {
|
||||
return map[string]interface{}{"group": group, "version": version, "kind": kind}
|
||||
}
|
||||
|
||||
func TestFilterScopedGVKs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
gvks []interface{}
|
||||
group string
|
||||
version string
|
||||
wantGVKs []interface{}
|
||||
}{
|
||||
{
|
||||
name: "DeleteOptions keeps local and core/v1",
|
||||
gvks: []interface{}{
|
||||
gvk("", "v1", "DeleteOptions"),
|
||||
gvk("apps", "v1", "DeleteOptions"),
|
||||
gvk("batch", "v1", "DeleteOptions"),
|
||||
gvk("autoscaling", "v1", "DeleteOptions"),
|
||||
},
|
||||
group: "apps",
|
||||
version: "v1",
|
||||
wantGVKs: []interface{}{
|
||||
gvk("", "v1", "DeleteOptions"),
|
||||
gvk("apps", "v1", "DeleteOptions"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "WatchEvent keeps local and core/v1",
|
||||
gvks: []interface{}{
|
||||
gvk("", "v1", "WatchEvent"),
|
||||
gvk("apps", "v1", "WatchEvent"),
|
||||
gvk("batch", "v1", "WatchEvent"),
|
||||
},
|
||||
group: "batch",
|
||||
version: "v1",
|
||||
wantGVKs: []interface{}{
|
||||
gvk("", "v1", "WatchEvent"),
|
||||
gvk("batch", "v1", "WatchEvent"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DeleteOptions with core group keeps only core/v1",
|
||||
gvks: []interface{}{
|
||||
gvk("", "v1", "DeleteOptions"),
|
||||
gvk("apps", "v1", "DeleteOptions"),
|
||||
gvk("batch", "v1", "DeleteOptions"),
|
||||
},
|
||||
group: "",
|
||||
version: "v1",
|
||||
wantGVKs: []interface{}{gvk("", "v1", "DeleteOptions")},
|
||||
},
|
||||
{
|
||||
name: "single GVK unchanged",
|
||||
gvks: []interface{}{
|
||||
gvk("apps", "v1", "Deployment"),
|
||||
},
|
||||
group: "apps",
|
||||
version: "v1",
|
||||
wantGVKs: []interface{}{gvk("apps", "v1", "Deployment")},
|
||||
},
|
||||
{
|
||||
name: "non-meta cross-group type unchanged",
|
||||
gvks: []interface{}{
|
||||
gvk("autoscaling", "v1", "Scale"),
|
||||
gvk("apps", "v1", "Scale"),
|
||||
},
|
||||
group: "apps",
|
||||
version: "v1",
|
||||
wantGVKs: []interface{}{
|
||||
gvk("autoscaling", "v1", "Scale"),
|
||||
gvk("apps", "v1", "Scale"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DeleteOptions no match removes extension",
|
||||
gvks: []interface{}{
|
||||
gvk("apps", "v1", "DeleteOptions"),
|
||||
gvk("batch", "v1", "DeleteOptions"),
|
||||
},
|
||||
group: "networking.k8s.io",
|
||||
version: "v1",
|
||||
wantGVKs: nil,
|
||||
},
|
||||
{
|
||||
name: "multi-version same group unchanged",
|
||||
gvks: []interface{}{
|
||||
gvk("autoscaling", "v1", "HorizontalPodAutoscaler"),
|
||||
gvk("autoscaling", "v2", "HorizontalPodAutoscaler"),
|
||||
},
|
||||
group: "autoscaling",
|
||||
version: "v2",
|
||||
wantGVKs: []interface{}{
|
||||
gvk("autoscaling", "v1", "HorizontalPodAutoscaler"),
|
||||
gvk("autoscaling", "v2", "HorizontalPodAutoscaler"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := &spec3.OpenAPI{
|
||||
Components: &spec3.Components{
|
||||
Schemas: map[string]*spec.Schema{
|
||||
"TestType": {
|
||||
VendorExtensible: spec.VendorExtensible{
|
||||
Extensions: spec.Extensions{
|
||||
"x-kubernetes-group-version-kind": tt.gvks,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
filterScopedGVKs(s, tt.group, tt.version)
|
||||
got, exists := s.Components.Schemas["TestType"].Extensions["x-kubernetes-group-version-kind"]
|
||||
if tt.wantGVKs == nil {
|
||||
if exists {
|
||||
t.Errorf("expected extension to be removed, got %v", got)
|
||||
}
|
||||
} else if !reflect.DeepEqual(got, tt.wantGVKs) {
|
||||
t.Errorf("got %v, want %v", got, tt.wantGVKs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user