Skip to content

Commit

Permalink
WIP Fix schema definition
Browse files Browse the repository at this point in the history
  • Loading branch information
tomleb committed May 16, 2024
1 parent e9aa3cb commit 5468cbf
Show file tree
Hide file tree
Showing 7 changed files with 1,082 additions and 219 deletions.
12 changes: 8 additions & 4 deletions pkg/schema/converter/k8stonorman.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,8 @@ func GVRToPluralName(gvr schema.GroupVersionResource) string {
return fmt.Sprintf("%s.%s", gvr.Group, gvr.Resource)
}

// GetGVKForKind attempts to retrieve a GVK for a given Kind. Not all kind represent top level resources,
// so this function may return nil if the kind did not have a gvk extension
func GetGVKForKind(kind *proto.Kind) *schema.GroupVersionKind {
extensions, ok := kind.Extensions[gvkExtensionName].([]any)
func GetGVKForProtoSchema(protoSchema proto.Schema) *schema.GroupVersionKind {
extensions, ok := protoSchema.GetExtensions()[gvkExtensionName].([]any)
if !ok {
return nil
}
Expand All @@ -69,6 +67,12 @@ func GetGVKForKind(kind *proto.Kind) *schema.GroupVersionKind {
return nil
}

// GetGVKForKind attempts to retrieve a GVK for a given Kind. Not all kind represent top level resources,
// so this function may return nil if the kind did not have a gvk extension
func GetGVKForKind(kind *proto.Kind) *schema.GroupVersionKind {
return GetGVKForProtoSchema(kind)
}

// ToSchemas creates the schemas for a K8s server, using client to discover groups/resources, and crd to potentially
// add additional information about new fields/resources. Mostly ties together addDiscovery and addCustomResources.
func ToSchemas(crd v1.CustomResourceDefinitionClient, client discovery.DiscoveryInterface) (map[string]*types.APISchema, error) {
Expand Down
223 changes: 223 additions & 0 deletions pkg/schema/definitions/converter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package definitions

import (
"errors"
"fmt"

"github.com/rancher/apiserver/pkg/types"
wapiextv1 "github.com/rancher/wrangler/v2/pkg/generated/controllers/apiextensions.k8s.io/v1"
wranglerDefinition "github.com/rancher/wrangler/v2/pkg/schemas/definition"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/kube-openapi/pkg/util/proto"
)

var (
ErrNotFound = errors.New("not found")
ErrNotRefreshed = errors.New("not refreshed")
)

// crdToDefinition builds a schemaDefinition for a CustomResourceDefinition
func crdToDefinition(crdCache wapiextv1.CustomResourceDefinitionCache, crdName string, modelName string, version string) (schemaDefinition, error) {
crd, err := crdCache.Get(crdName)
if err != nil {
if apierrors.IsNotFound(err) {
return schemaDefinition{}, ErrNotFound
}
return schemaDefinition{}, err
}

var jsonSchemaProps *apiextv1.JSONSchemaProps
for _, crdVersion := range crd.Spec.Versions {
if crdVersion.Name == version {
jsonSchemaProps = crdVersion.Schema.OpenAPIV3Schema
break
}
}

if jsonSchemaProps == nil {
return schemaDefinition{}, fmt.Errorf("unknown version %q for CRD %q", version, crdName)
}

// CRD definitions generally has more information than the OpenAPI V2
// because it embeds an OpenAPI V3 document. However, these 3 fields
// are the exception where the Open API V2 endpoint has more
// information.
//
// To avoid overriding these later on, we remove them here. Yeah, really.
delete(jsonSchemaProps.Properties, "apiVersion")
delete(jsonSchemaProps.Properties, "kind")
delete(jsonSchemaProps.Properties, "metadata")

path := proto.NewPath(modelName)

definitions := make(map[string]definition)
convertJSONSchemaPropsToDefinition(*jsonSchemaProps, path, definitions)

return schemaDefinition{
DefinitionType: modelName,
Definitions: definitions,
}, nil
}

func convertJSONSchemaPropsToDefinition(props apiextv1.JSONSchemaProps, path proto.Path, definitions map[string]definition) {
if props.Type != "array" && props.Type != "object" {
return
}

// Get the properties of the items inside the array
if props.Type == "array" {
items := getItemsSchema(props)
if items == nil {
return
}
props = *items
}

def := definition{
Type: path.String(),
Description: props.Description,
ResourceFields: map[string]definitionField{},
}

requiredSet := make(map[string]struct{})
for _, name := range props.Required {
requiredSet[name] = struct{}{}
}

for name, prop := range props.Properties {
_, required := requiredSet[name]
field := convertJSONSchemaPropsToDefinitionField(prop, path.FieldPath(name), required)
def.ResourceFields[name] = field

convertJSONSchemaPropsToDefinition(prop, path.FieldPath(name), definitions)
}
definitions[path.String()] = def
}

func convertJSONSchemaPropsToDefinitionField(props apiextv1.JSONSchemaProps, path proto.Path, required bool) definitionField {
field := definitionField{
Description: props.Description,
Required: required,
Type: getPrimitiveType(props.Type),
}
switch props.Type {
case "array":
field.Type = "array"
if item := getItemsSchema(props); item != nil {
if item.Type == "object" || item.Type == "array" {
field.SubType = path.String()
} else {
field.SubType = getPrimitiveType(item.Type)
}
}
case "object":
field.Type = path.String()
}
return field
}

func getPrimitiveType(typ string) string {
switch typ {
case "string":
return "string"
case "boolean":
return "boolean"
case "integer", "number":
return "int"
}
return ""
}

func getItemsSchema(props apiextv1.JSONSchemaProps) *apiextv1.JSONSchemaProps {
if props.Items == nil {
return nil
}

if props.Items.Schema != nil {
return props.Items.Schema
} else if len(props.Items.JSONSchemas) > 0 {
return &props.Items.JSONSchemas[0]
}
return nil
}

// openAPIV2ToDefinition builds a schemaDefinition for the given schemaID based on
// Resource information from OpenAPI v2 endpoint
func openAPIV2ToDefinition(models proto.Models, modelName string, version string) (schemaDefinition, error) {
protoSchema := models.LookupModel(modelName)
switch m := protoSchema.(type) {
case *proto.Map:
// If the schema is a *proto.Map, it will not have any Fields associated with it
// even though all Kubernetes resources have at least apiVersion, kind and metadata.
//
// We transform this Map to a Kind and inject these fields from
// a known existing Kubernetes resource (ConfigMap).
configMap := models.LookupModel("io.k8s.api.core.v1.ConfigMap")
apiVersion := configMap.(*proto.Kind).Fields["apiVersion"]
apiVersion.(*proto.Primitive).Path = m.Path.FieldPath("apiVersion")
kind := configMap.(*proto.Kind).Fields["kind"]
kind.(*proto.Primitive).Path = m.Path.FieldPath("kind")
metadata := configMap.(*proto.Kind).Fields["metadata"]
metadata.(*proto.Ref).Path = m.Path.FieldPath("metadata")
protoSchema = &proto.Kind{
BaseSchema: m.BaseSchema,
Fields: map[string]proto.Schema{
"apiVersion": apiVersion,
"kind": kind,
"metadata": metadata,
},
}
case *proto.Kind:
default:
return schemaDefinition{}, fmt.Errorf("model for %s was type %T, not a *proto.Kind nor *proto.Map", modelName, protoSchema)
}
definitions := map[string]definition{}
visitor := schemaFieldVisitor{
definitions: definitions,
models: models,
}
protoSchema.Accept(&visitor)

return schemaDefinition{
DefinitionType: modelName,
Definitions: definitions,
}, nil
}

// baseSchemaToDefinition converts a given schema to the definition map. This should only be used with baseSchemas, whose definitions
// are expected to be set by another application and may not be k8s resources.
func baseSchemaToDefinition(schema types.APISchema) map[string]definition {
definitions := map[string]definition{}
def := definition{
Description: schema.Description,
Type: schema.ID,
ResourceFields: map[string]definitionField{},
}
for fieldName, field := range schema.ResourceFields {
fieldType, subType := parseFieldType(field.Type)
def.ResourceFields[fieldName] = definitionField{
Type: fieldType,
SubType: subType,
Description: field.Description,
Required: field.Required,
}
}
definitions[schema.ID] = def
return definitions
}

// parseFieldType parses a schemas.Field's type to a type (first return) and subType (second return)
func parseFieldType(fieldType string) (string, string) {
subType := wranglerDefinition.SubType(fieldType)
if wranglerDefinition.IsMapType(fieldType) {
return "map", subType
}
if wranglerDefinition.IsArrayType(fieldType) {
return "array", subType
}
if wranglerDefinition.IsReferenceType(fieldType) {
return "reference", subType
}
return fieldType, ""
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
package definitions

import (
"bytes"
"fmt"

"github.com/rancher/wrangler/v2/pkg/yaml"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
)

var (
rawCRDs = `apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: userattributes.management.cattle.io
spec:
conversion:
strategy: None
group: management.cattle.io
names:
kind: UserAttribute
listKind: UserAttributeList
plural: userattributes
singular: userattribute
scope: Cluster
versions:
- name: v2
schema:
openAPIV3Schema:
type: object
x-kubernetes-preserve-unknown-fields: true
served: true
storage: true
`
)

func getCRDs() ([]*apiextv1.CustomResourceDefinition, error) {
crds, err := yaml.UnmarshalWithJSONDecoder[*apiextv1.CustomResourceDefinition](bytes.NewBuffer([]byte(rawCRDs)))
if err != nil {
return nil, fmt.Errorf("unmarshal CRD: %w", err)
}
return crds, err
}

const openapi_raw = `
swagger: "2.0"
info:
Expand Down Expand Up @@ -172,6 +214,23 @@ definitions:
- group: "noversion.cattle.io"
version: "v1"
kind: "Resource"
io.cattle.management.v1.DeprecatedResource:
description: "A resource that is not present in v2"
type: "object"
properties:
apiVersion:
description: "The APIVersion of this resource"
type: "string"
kind:
description: "The kind"
type: "string"
metadata:
description: "The metadata"
$ref: "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"
x-kubernetes-group-version-kind:
- group: "management.cattle.io"
version: "v1"
kind: "DeprecatedResource"
io.cattle.missinggroup.v2.Resource:
description: "A Missing Group V2 resource is for a group not listed by server groups"
type: "object"
Expand Down Expand Up @@ -236,6 +295,12 @@ definitions:
io.cattle.management.NotAKind:
type: "string"
description: "Some string which isn't a kind"
io.cattle.management.v2.UserAttribute:
type: "object"
x-kubernetes-group-version-kind:
- group: "management.cattle.io"
version: "v2"
kind: "UserAttribute"
io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta:
description: "Object Metadata"
properties:
Expand All @@ -247,4 +312,35 @@ definitions:
name:
description: "name of the resource"
type: "string"
io.k8s.api.core.v1.ConfigMap:
type: "object"
description: "ConfigMap holds configuration data for pods to consume."
properties:
apiVersion:
description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources"
type: "string"
kind:
description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds"
type: "string"
metadata:
description: "Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata"
$ref: "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"
binaryData:
description: "BinaryData contains the binary data. Each key must consist of alphanumeric characters, '-', '_' or '.'. BinaryData can contain byte sequences that are not in the UTF-8 range. The keys stored in BinaryData must not overlap with the ones in the Data field, this is enforced during validation process. Using this field will require 1.10+ apiserver and kubelet."
type: "object"
additionalProperties:
type: "string"
format: "byte"
data:
description: "Data contains the configuration data. Each key must consist of alphanumeric characters, '-', '_' or '.'. Values with non-UTF-8 byte sequences must use the BinaryData field. The keys stored in Data must not overlap with the keys in the BinaryData field, this is enforced during validation process."
type: "object"
additionalProperties:
type: "string"
immutable:
description: "Immutable, if set to true, ensures that data stored in the ConfigMap cannot be updated (only object metadata can be modified). If not set to true, the field can be modified at any time. Defaulted to nil."
type: "boolean"
x-kubernetes-group-version-kind:
- group: ""
kind: "ConfigMap"
version: "v1"
`
Loading

0 comments on commit 5468cbf

Please sign in to comment.