mirror of
https://github.com/kubernetes-sigs/kustomize.git
synced 2026-09-15 12:18:57 +00:00
chore: refactor read scheme to openapi from kubernetes api definition
Previously, only a single embedded Kubernetes API version could be specified, resulting in a lack of support for certain GVKs. To address this, the goal is to create and utilize a unified scheme that consolidates Kubernetes API definitions. As a preliminary step, the current method of loading API definitions will be improved.
This commit is contained in:
223
kyaml/openapi/internal/builtinopenapi/bundle.go
Normal file
223
kyaml/openapi/internal/builtinopenapi/bundle.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// Copyright 2026 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package builtinopenapi defines the on-disk format of the compiled built-in
|
||||
// Kubernetes OpenAPI bundle.
|
||||
package builtinopenapi
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
)
|
||||
|
||||
const (
|
||||
// FormatVersion is the version of the bundle's JSON representation.
|
||||
FormatVersion = 1
|
||||
|
||||
// SelectionPolicy identifies how schemas from Kubernetes releases are
|
||||
// selected. A single-release bundle is the degenerate case of this policy.
|
||||
SelectionPolicy = "latest-wins-fill-missing"
|
||||
|
||||
gvkExtension = "x-kubernetes-group-version-kind"
|
||||
)
|
||||
|
||||
// Scope describes whether a Kubernetes resource is namespace or cluster
|
||||
// scoped. An empty Scope means that the source OpenAPI document did not expose
|
||||
// a resource path from which scope could be determined.
|
||||
type Scope string
|
||||
|
||||
const (
|
||||
ScopeUnknown Scope = ""
|
||||
ScopeNamespaced Scope = "Namespaced"
|
||||
ScopeCluster Scope = "Cluster"
|
||||
)
|
||||
|
||||
// Coverage identifies the Kubernetes release range represented by a bundle.
|
||||
type Coverage struct {
|
||||
Floor string `json:"floor"`
|
||||
Ceiling string `json:"ceiling"`
|
||||
}
|
||||
|
||||
// Source identifies one OpenAPI input used to compile a bundle.
|
||||
type Source struct {
|
||||
KubernetesVersion string `json:"kubernetesVersion"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
// Resource maps a GVK to its root definition and, when known, its scope.
|
||||
// Definition may be empty for a GVK that was present in an API path but not in
|
||||
// the OpenAPI definitions.
|
||||
type Resource struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Kind string `json:"kind"`
|
||||
Definition string `json:"definition,omitempty"`
|
||||
Scope Scope `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// Bundle is the compiled representation consumed by kyaml at runtime.
|
||||
type Bundle struct {
|
||||
FormatVersion int `json:"formatVersion"`
|
||||
Coverage Coverage `json:"coverage"`
|
||||
SelectionPolicy string `json:"selectionPolicy"`
|
||||
Sources []Source `json:"sources"`
|
||||
Definitions spec.Definitions `json:"definitions"`
|
||||
Resources []Resource `json:"resources"`
|
||||
}
|
||||
|
||||
// Validate checks invariants required by the runtime loader.
|
||||
func (b *Bundle) Validate() error {
|
||||
if b.FormatVersion != FormatVersion {
|
||||
return fmt.Errorf("unsupported built-in OpenAPI bundle format %d", b.FormatVersion)
|
||||
}
|
||||
if b.Coverage.Floor == "" || b.Coverage.Ceiling == "" {
|
||||
return fmt.Errorf("built-in OpenAPI bundle coverage is incomplete")
|
||||
}
|
||||
if b.SelectionPolicy != SelectionPolicy {
|
||||
return fmt.Errorf("unsupported built-in OpenAPI selection policy %q", b.SelectionPolicy)
|
||||
}
|
||||
if len(b.Sources) == 0 {
|
||||
return fmt.Errorf("built-in OpenAPI bundle has no sources")
|
||||
}
|
||||
for _, source := range b.Sources {
|
||||
if source.KubernetesVersion == "" {
|
||||
return fmt.Errorf("built-in OpenAPI bundle has a source without a Kubernetes version")
|
||||
}
|
||||
if len(source.SHA256) != 64 {
|
||||
return fmt.Errorf("built-in OpenAPI source %q has an invalid SHA-256", source.KubernetesVersion)
|
||||
}
|
||||
if _, err := hex.DecodeString(source.SHA256); err != nil {
|
||||
return fmt.Errorf("built-in OpenAPI source %q has an invalid SHA-256", source.KubernetesVersion)
|
||||
}
|
||||
}
|
||||
if len(b.Definitions) == 0 {
|
||||
return fmt.Errorf("built-in OpenAPI bundle has no definitions")
|
||||
}
|
||||
if len(b.Resources) == 0 {
|
||||
return fmt.Errorf("built-in OpenAPI bundle has no resources")
|
||||
}
|
||||
|
||||
resourcesByGVK := make(map[string]Resource, len(b.Resources))
|
||||
for i, resource := range b.Resources {
|
||||
if resource.APIVersion == "" || resource.Kind == "" {
|
||||
return fmt.Errorf("built-in OpenAPI resource %d has an incomplete GVK", i)
|
||||
}
|
||||
switch resource.Scope {
|
||||
case ScopeUnknown, ScopeNamespaced, ScopeCluster:
|
||||
default:
|
||||
return fmt.Errorf("built-in OpenAPI resource %s/%s has invalid scope %q",
|
||||
resource.APIVersion, resource.Kind, resource.Scope)
|
||||
}
|
||||
if resource.Definition != "" {
|
||||
if _, found := b.Definitions[resource.Definition]; !found {
|
||||
return fmt.Errorf("built-in OpenAPI resource %s/%s references missing definition %q",
|
||||
resource.APIVersion, resource.Kind, resource.Definition)
|
||||
}
|
||||
}
|
||||
key := resourceKey(resource.APIVersion, resource.Kind)
|
||||
if _, found := resourcesByGVK[key]; found {
|
||||
return fmt.Errorf("built-in OpenAPI resource %s/%s is duplicated",
|
||||
resource.APIVersion, resource.Kind)
|
||||
}
|
||||
resourcesByGVK[key] = resource
|
||||
if i > 0 && lessResource(resource, b.Resources[i-1]) {
|
||||
return fmt.Errorf("built-in OpenAPI resources are not sorted")
|
||||
}
|
||||
}
|
||||
return validateDefinitionResources(b.Definitions, resourcesByGVK)
|
||||
}
|
||||
|
||||
func validateDefinitionResources(definitions spec.Definitions, resourcesByGVK map[string]Resource) error {
|
||||
definitionsByGVK := make(map[string]string)
|
||||
for definitionName, definition := range definitions {
|
||||
extension, found := definition.Extensions[gvkExtension]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
entries, ok := extension.([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("built-in OpenAPI definition %q has malformed %s extension: expected an array",
|
||||
definitionName, gvkExtension)
|
||||
}
|
||||
for i, entry := range entries {
|
||||
apiVersion, kind, err := parseGVK(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("built-in OpenAPI definition %q has malformed %s extension entry %d: %w",
|
||||
definitionName, gvkExtension, i, err)
|
||||
}
|
||||
key := resourceKey(apiVersion, kind)
|
||||
if previousDefinition, found := definitionsByGVK[key]; found && previousDefinition != definitionName {
|
||||
return fmt.Errorf("built-in OpenAPI GVK %s/%s is advertised by definitions %q and %q",
|
||||
apiVersion, kind, previousDefinition, definitionName)
|
||||
}
|
||||
definitionsByGVK[key] = definitionName
|
||||
|
||||
resource, found := resourcesByGVK[key]
|
||||
if !found {
|
||||
return fmt.Errorf("built-in OpenAPI definition %q advertises GVK %s/%s without a resource mapping",
|
||||
definitionName, apiVersion, kind)
|
||||
}
|
||||
if resource.Definition != definitionName {
|
||||
return fmt.Errorf("built-in OpenAPI resource %s/%s references definition %q, but definition %q advertises it",
|
||||
apiVersion, kind, resource.Definition, definitionName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, resource := range resourcesByGVK {
|
||||
if resource.Definition == "" {
|
||||
continue
|
||||
}
|
||||
definitionName, found := definitionsByGVK[resourceKey(resource.APIVersion, resource.Kind)]
|
||||
if !found {
|
||||
return fmt.Errorf("built-in OpenAPI resource %s/%s references definition %q, but that definition does not advertise the GVK",
|
||||
resource.APIVersion, resource.Kind, resource.Definition)
|
||||
}
|
||||
if definitionName != resource.Definition {
|
||||
return fmt.Errorf("built-in OpenAPI resource %s/%s references definition %q, but definition %q advertises it",
|
||||
resource.APIVersion, resource.Kind, resource.Definition, definitionName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseGVK(value interface{}) (string, string, error) {
|
||||
entry, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("expected an object")
|
||||
}
|
||||
version, versionOK := entry["version"].(string)
|
||||
kind, kindOK := entry["kind"].(string)
|
||||
if !versionOK || version == "" || !kindOK || kind == "" {
|
||||
return "", "", fmt.Errorf("version and kind must be non-empty strings")
|
||||
}
|
||||
groupValue, hasGroup := entry["group"]
|
||||
group, groupOK := groupValue.(string)
|
||||
if hasGroup && !groupOK {
|
||||
return "", "", fmt.Errorf("group must be a string")
|
||||
}
|
||||
if group != "" {
|
||||
return group + "/" + version, kind, nil
|
||||
}
|
||||
return version, kind, nil
|
||||
}
|
||||
|
||||
func resourceKey(apiVersion, kind string) string {
|
||||
return apiVersion + "\x00" + kind
|
||||
}
|
||||
|
||||
// SortResources orders resources deterministically for serialization.
|
||||
func SortResources(resources []Resource) {
|
||||
sort.Slice(resources, func(i, j int) bool {
|
||||
return lessResource(resources[i], resources[j])
|
||||
})
|
||||
}
|
||||
|
||||
func lessResource(left, right Resource) bool {
|
||||
if left.APIVersion != right.APIVersion {
|
||||
return left.APIVersion < right.APIVersion
|
||||
}
|
||||
return left.Kind < right.Kind
|
||||
}
|
||||
128
kyaml/openapi/internal/builtinopenapi/bundle_test.go
Normal file
128
kyaml/openapi/internal/builtinopenapi/bundle_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
// Copyright 2026 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package builtinopenapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
)
|
||||
|
||||
func TestBundleValidate(t *testing.T) {
|
||||
valid := func() Bundle {
|
||||
definition := spec.Schema{}
|
||||
definition.Extensions = spec.Extensions{
|
||||
gvkExtension: []interface{}{
|
||||
map[string]interface{}{"group": "apps", "version": "v1", "kind": "Deployment"},
|
||||
},
|
||||
}
|
||||
return Bundle{
|
||||
FormatVersion: FormatVersion,
|
||||
Coverage: Coverage{Floor: "v1.21.2", Ceiling: "v1.21.2"},
|
||||
SelectionPolicy: SelectionPolicy,
|
||||
Sources: []Source{{
|
||||
KubernetesVersion: "v1.21.2",
|
||||
SHA256: "5d171b55e9601912807a870d73ffe70bb306f5889a00e76986042a0f2d7b6bc2",
|
||||
}},
|
||||
Definitions: spec.Definitions{"definition": definition},
|
||||
Resources: []Resource{{
|
||||
APIVersion: "apps/v1",
|
||||
Kind: "Deployment",
|
||||
Definition: "definition",
|
||||
Scope: ScopeNamespaced,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
tests := map[string]func(*Bundle){
|
||||
"format": func(bundle *Bundle) { bundle.FormatVersion++ },
|
||||
"coverage": func(bundle *Bundle) { bundle.Coverage.Floor = "" },
|
||||
"policy": func(bundle *Bundle) { bundle.SelectionPolicy = "unknown" },
|
||||
"source": func(bundle *Bundle) { bundle.Sources[0].SHA256 = "short" },
|
||||
"source hex": func(bundle *Bundle) {
|
||||
bundle.Sources[0].SHA256 = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
|
||||
},
|
||||
"definition": func(bundle *Bundle) { bundle.Resources[0].Definition = "missing" },
|
||||
"no definitions": func(bundle *Bundle) {
|
||||
bundle.Definitions = nil
|
||||
},
|
||||
"no resources": func(bundle *Bundle) {
|
||||
bundle.Resources = nil
|
||||
},
|
||||
"scope": func(bundle *Bundle) { bundle.Resources[0].Scope = "invalid" },
|
||||
"duplicate": func(bundle *Bundle) {
|
||||
bundle.Resources = append(bundle.Resources, bundle.Resources[0])
|
||||
},
|
||||
"duplicate with different definition": func(bundle *Bundle) {
|
||||
secondDefinition := bundle.Definitions["definition"]
|
||||
bundle.Definitions["second-definition"] = secondDefinition
|
||||
duplicate := bundle.Resources[0]
|
||||
duplicate.Definition = "second-definition"
|
||||
bundle.Resources = append(bundle.Resources, duplicate)
|
||||
},
|
||||
"GVK advertised by different definitions": func(bundle *Bundle) {
|
||||
secondDefinition := bundle.Definitions["definition"]
|
||||
bundle.Definitions["second-definition"] = secondDefinition
|
||||
},
|
||||
"order": func(bundle *Bundle) {
|
||||
bundle.Resources = append([]Resource{{APIVersion: "v1", Kind: "Pod"}}, bundle.Resources...)
|
||||
},
|
||||
"malformed definition extension type": func(bundle *Bundle) {
|
||||
definition := bundle.Definitions["definition"]
|
||||
definition.Extensions[gvkExtension] = map[string]interface{}{}
|
||||
bundle.Definitions["definition"] = definition
|
||||
},
|
||||
"malformed definition extension entry": func(bundle *Bundle) {
|
||||
definition := bundle.Definitions["definition"]
|
||||
definition.Extensions[gvkExtension] = []interface{}{map[string]interface{}{"version": "v1"}}
|
||||
bundle.Definitions["definition"] = definition
|
||||
},
|
||||
"malformed definition extension group": func(bundle *Bundle) {
|
||||
definition := bundle.Definitions["definition"]
|
||||
definition.Extensions[gvkExtension] = []interface{}{
|
||||
map[string]interface{}{"group": 1, "version": "v1", "kind": "Deployment"},
|
||||
}
|
||||
bundle.Definitions["definition"] = definition
|
||||
},
|
||||
"resource GVK absent from definition extension": func(bundle *Bundle) {
|
||||
definition := bundle.Definitions["definition"]
|
||||
definition.Extensions[gvkExtension] = []interface{}{}
|
||||
bundle.Definitions["definition"] = definition
|
||||
},
|
||||
"definition GVK absent from resources": func(bundle *Bundle) {
|
||||
definition := bundle.Definitions["definition"]
|
||||
definition.Extensions[gvkExtension] = []interface{}{
|
||||
map[string]interface{}{"group": "apps", "version": "v1", "kind": "Deployment"},
|
||||
map[string]interface{}{"group": "apps", "version": "v1", "kind": "StatefulSet"},
|
||||
}
|
||||
bundle.Definitions["definition"] = definition
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, func() error { bundle := valid(); return bundle.Validate() }())
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
bundle := valid()
|
||||
mutate(&bundle)
|
||||
require.Error(t, bundle.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLessResourceUsesOnlyGVK(t *testing.T) {
|
||||
left := Resource{APIVersion: "apps/v1", Kind: "Deployment", Definition: "z"}
|
||||
right := Resource{APIVersion: "apps/v1", Kind: "Deployment", Definition: "a"}
|
||||
require.False(t, lessResource(left, right))
|
||||
require.False(t, lessResource(right, left))
|
||||
|
||||
require.True(t, lessResource(
|
||||
Resource{APIVersion: "apps/v1", Kind: "Deployment"},
|
||||
Resource{APIVersion: "apps/v1", Kind: "StatefulSet"},
|
||||
))
|
||||
require.True(t, lessResource(
|
||||
Resource{APIVersion: "apps/v1", Kind: "Deployment"},
|
||||
Resource{APIVersion: "batch/v1", Kind: "CronJob"},
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user