openapi: filter cross-registered GVKs in per-GV v3 specs

This commit is contained in:
Jefftree
2026-03-12 09:45:55 -04:00
parent 9874e76ac4
commit f2ae2cc046
2 changed files with 286 additions and 2 deletions

View File

@@ -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]
}

View 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)
}
})
}
}