442 lines
13 KiB
Go
442 lines
13 KiB
Go
|
/*
|
||
|
Copyright 2019 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 crd
|
||
|
|
||
|
import (
|
||
|
"fmt"
|
||
|
"reflect"
|
||
|
"sort"
|
||
|
"strings"
|
||
|
"sync"
|
||
|
|
||
|
apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||
|
|
||
|
"sigs.k8s.io/controller-tools/pkg/loader"
|
||
|
)
|
||
|
|
||
|
// ErrorRecorder knows how to record errors. It wraps the part of
|
||
|
// pkg/loader.Package that we need to record errors in places were it might not
|
||
|
// make sense to have a loader.Package
|
||
|
type ErrorRecorder interface {
|
||
|
// AddError records that the given error occurred.
|
||
|
// See the documentation on loader.Package.AddError for more information.
|
||
|
AddError(error)
|
||
|
}
|
||
|
|
||
|
// isOrNil checks if val is nil if val is of a nillable type, otherwise,
|
||
|
// it compares val to valInt (which should probably be the zero value).
|
||
|
func isOrNil(val reflect.Value, valInt interface{}, zeroInt interface{}) bool {
|
||
|
switch valKind := val.Kind(); valKind {
|
||
|
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
|
||
|
return val.IsNil()
|
||
|
default:
|
||
|
return valInt == zeroInt
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// flattenAllOfInto copies properties from src to dst, then copies the properties
|
||
|
// of each item in src's allOf to dst's properties as well.
|
||
|
func flattenAllOfInto(dst *apiext.JSONSchemaProps, src apiext.JSONSchemaProps, errRec ErrorRecorder) {
|
||
|
if len(src.AllOf) > 0 {
|
||
|
for _, embedded := range src.AllOf {
|
||
|
flattenAllOfInto(dst, embedded, errRec)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
dstVal := reflect.Indirect(reflect.ValueOf(dst))
|
||
|
srcVal := reflect.ValueOf(src)
|
||
|
typ := dstVal.Type()
|
||
|
|
||
|
srcRemainder := apiext.JSONSchemaProps{}
|
||
|
srcRemVal := reflect.Indirect(reflect.ValueOf(&srcRemainder))
|
||
|
dstRemainder := apiext.JSONSchemaProps{}
|
||
|
dstRemVal := reflect.Indirect(reflect.ValueOf(&dstRemainder))
|
||
|
hoisted := false
|
||
|
|
||
|
for i := 0; i < srcVal.NumField(); i++ {
|
||
|
fieldName := typ.Field(i).Name
|
||
|
switch fieldName {
|
||
|
case "AllOf":
|
||
|
// don't merge because we deal with it above
|
||
|
continue
|
||
|
case "Title", "Description", "Example", "ExternalDocs":
|
||
|
// don't merge because we pre-merge to properly preserve field docs
|
||
|
continue
|
||
|
}
|
||
|
srcField := srcVal.Field(i)
|
||
|
fldTyp := srcField.Type()
|
||
|
zeroVal := reflect.Zero(fldTyp)
|
||
|
zeroInt := zeroVal.Interface()
|
||
|
srcInt := srcField.Interface()
|
||
|
|
||
|
if isOrNil(srcField, srcInt, zeroInt) {
|
||
|
// nothing to copy from src, continue
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
dstField := dstVal.Field(i)
|
||
|
dstInt := dstField.Interface()
|
||
|
if isOrNil(dstField, dstInt, zeroInt) {
|
||
|
// dst is empty, continue
|
||
|
dstField.Set(srcField)
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
if fldTyp.Comparable() && srcInt == dstInt {
|
||
|
// same value, continue
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
// resolve conflict
|
||
|
switch fieldName {
|
||
|
case "Properties":
|
||
|
// merge if possible, use all of otherwise
|
||
|
srcMap := srcInt.(map[string]apiext.JSONSchemaProps)
|
||
|
dstMap := dstInt.(map[string]apiext.JSONSchemaProps)
|
||
|
|
||
|
for k, v := range srcMap {
|
||
|
dstProp, exists := dstMap[k]
|
||
|
if !exists {
|
||
|
dstMap[k] = v
|
||
|
continue
|
||
|
}
|
||
|
flattenAllOfInto(&dstProp, v, errRec)
|
||
|
dstMap[k] = dstProp
|
||
|
}
|
||
|
case "Required":
|
||
|
// merge
|
||
|
dstField.Set(reflect.AppendSlice(dstField, srcField))
|
||
|
case "Type":
|
||
|
if srcInt != dstInt {
|
||
|
// TODO(directxman12): figure out how to attach this back to a useful point in the Go source or in the schema
|
||
|
errRec.AddError(fmt.Errorf("conflicting types in allOf branches in schema: %s vs %s", dstInt, srcInt))
|
||
|
}
|
||
|
// keep the destination value, for now
|
||
|
// TODO(directxman12): Default -- use field?
|
||
|
// TODO(directxman12):
|
||
|
// - Dependencies: if field x is present, then either schema validates or all props are present
|
||
|
// - AdditionalItems: like AdditionalProperties
|
||
|
// - Definitions: common named validation sets that can be references (merge, bail if duplicate)
|
||
|
case "AdditionalProperties":
|
||
|
// as of the time of writing, `allows: false` is not allowed, so we don't have to handle it
|
||
|
srcProps := srcInt.(*apiext.JSONSchemaPropsOrBool)
|
||
|
if srcProps.Schema == nil {
|
||
|
// nothing to merge
|
||
|
continue
|
||
|
}
|
||
|
dstProps := dstInt.(*apiext.JSONSchemaPropsOrBool)
|
||
|
if dstProps.Schema == nil {
|
||
|
dstProps.Schema = &apiext.JSONSchemaProps{}
|
||
|
}
|
||
|
flattenAllOfInto(dstProps.Schema, *srcProps.Schema, errRec)
|
||
|
// NB(directxman12): no need to explicitly handle nullable -- false is considered to be the zero value
|
||
|
// TODO(directxman12): src isn't necessarily the field value -- it's just the most recent allOf entry
|
||
|
default:
|
||
|
// hoist into allOf...
|
||
|
hoisted = true
|
||
|
|
||
|
srcRemVal.Field(i).Set(srcField)
|
||
|
dstRemVal.Field(i).Set(dstField)
|
||
|
// ...and clear the original
|
||
|
dstField.Set(zeroVal)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if hoisted {
|
||
|
dst.AllOf = append(dst.AllOf, dstRemainder, srcRemainder)
|
||
|
}
|
||
|
|
||
|
// dedup required
|
||
|
if len(dst.Required) > 0 {
|
||
|
reqUniq := make(map[string]struct{})
|
||
|
for _, req := range dst.Required {
|
||
|
reqUniq[req] = struct{}{}
|
||
|
}
|
||
|
dst.Required = make([]string, 0, len(reqUniq))
|
||
|
for req := range reqUniq {
|
||
|
dst.Required = append(dst.Required, req)
|
||
|
}
|
||
|
// be deterministic
|
||
|
sort.Strings(dst.Required)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// allOfVisitor recursively visits allOf fields in the schema,
|
||
|
// merging nested allOf properties into the root schema.
|
||
|
type allOfVisitor struct {
|
||
|
// errRec is used to record errors while flattening (like two conflicting
|
||
|
// field values used in an allOf)
|
||
|
errRec ErrorRecorder
|
||
|
}
|
||
|
|
||
|
func (v *allOfVisitor) Visit(schema *apiext.JSONSchemaProps) SchemaVisitor {
|
||
|
if schema == nil {
|
||
|
return v
|
||
|
}
|
||
|
|
||
|
// clear this now so that we can safely preserve edits made my flattenAllOfInto
|
||
|
origAllOf := schema.AllOf
|
||
|
schema.AllOf = nil
|
||
|
|
||
|
for _, embedded := range origAllOf {
|
||
|
flattenAllOfInto(schema, embedded, v.errRec)
|
||
|
}
|
||
|
return v
|
||
|
}
|
||
|
|
||
|
// NB(directxman12): FlattenEmbedded is separate from Flattener because
|
||
|
// some tooling wants to flatten out embedded fields, but only actually
|
||
|
// flatten a few specific types first.
|
||
|
|
||
|
// FlattenEmbedded flattens embedded fields (represented via AllOf) which have
|
||
|
// already had their references resolved into simple properties in the containing
|
||
|
// schema.
|
||
|
func FlattenEmbedded(schema *apiext.JSONSchemaProps, errRec ErrorRecorder) *apiext.JSONSchemaProps {
|
||
|
outSchema := schema.DeepCopy()
|
||
|
EditSchema(outSchema, &allOfVisitor{errRec: errRec})
|
||
|
return outSchema
|
||
|
}
|
||
|
|
||
|
// Flattener knows how to take a root type, and flatten all references in it
|
||
|
// into a single, flat type. Flattened types are cached, so it's relatively
|
||
|
// cheap to make repeated calls with the same type.
|
||
|
type Flattener struct {
|
||
|
// Parser is used to lookup package and type details, and parse in new packages.
|
||
|
Parser *Parser
|
||
|
|
||
|
LookupReference func(ref string, contextPkg *loader.Package) (TypeIdent, error)
|
||
|
|
||
|
// flattenedTypes hold the flattened version of each seen type for later reuse.
|
||
|
flattenedTypes map[TypeIdent]apiext.JSONSchemaProps
|
||
|
initOnce sync.Once
|
||
|
}
|
||
|
|
||
|
func (f *Flattener) init() {
|
||
|
f.initOnce.Do(func() {
|
||
|
f.flattenedTypes = make(map[TypeIdent]apiext.JSONSchemaProps)
|
||
|
if f.LookupReference == nil {
|
||
|
f.LookupReference = identFromRef
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
|
||
|
// cacheType saves the flattened version of the given type for later reuse
|
||
|
func (f *Flattener) cacheType(typ TypeIdent, schema apiext.JSONSchemaProps) {
|
||
|
f.init()
|
||
|
f.flattenedTypes[typ] = schema
|
||
|
}
|
||
|
|
||
|
// loadUnflattenedSchema fetches a fresh, unflattened schema from the parser.
|
||
|
func (f *Flattener) loadUnflattenedSchema(typ TypeIdent) (*apiext.JSONSchemaProps, error) {
|
||
|
f.Parser.NeedSchemaFor(typ)
|
||
|
|
||
|
baseSchema, found := f.Parser.Schemata[typ]
|
||
|
if !found {
|
||
|
return nil, fmt.Errorf("unable to locate schema for type %s", typ)
|
||
|
}
|
||
|
return &baseSchema, nil
|
||
|
}
|
||
|
|
||
|
// FlattenType flattens the given pre-loaded type, removing any references from it.
|
||
|
// It deep-copies the schema first, so it won't affect the parser's version of the schema.
|
||
|
func (f *Flattener) FlattenType(typ TypeIdent) *apiext.JSONSchemaProps {
|
||
|
f.init()
|
||
|
if cachedSchema, isCached := f.flattenedTypes[typ]; isCached {
|
||
|
return &cachedSchema
|
||
|
}
|
||
|
baseSchema, err := f.loadUnflattenedSchema(typ)
|
||
|
if err != nil {
|
||
|
typ.Package.AddError(err)
|
||
|
return nil
|
||
|
}
|
||
|
resSchema := f.FlattenSchema(*baseSchema, typ.Package)
|
||
|
f.cacheType(typ, *resSchema)
|
||
|
return resSchema
|
||
|
}
|
||
|
|
||
|
// FlattenSchema flattens the given schema, removing any references.
|
||
|
// It deep-copies the schema first, so the input schema won't be affected.
|
||
|
func (f *Flattener) FlattenSchema(baseSchema apiext.JSONSchemaProps, currentPackage *loader.Package) *apiext.JSONSchemaProps {
|
||
|
resSchema := baseSchema.DeepCopy()
|
||
|
EditSchema(resSchema, &flattenVisitor{
|
||
|
Flattener: f,
|
||
|
currentPackage: currentPackage,
|
||
|
})
|
||
|
|
||
|
return resSchema
|
||
|
}
|
||
|
|
||
|
// RefParts splits a reference produced by the schema generator into its component
|
||
|
// type name and package name (if it's a cross-package reference). Note that
|
||
|
// referenced packages *must* be looked up relative to the current package.
|
||
|
func RefParts(ref string) (typ string, pkgName string, err error) {
|
||
|
if !strings.HasPrefix(ref, defPrefix) {
|
||
|
return "", "", fmt.Errorf("non-standard reference link %q", ref)
|
||
|
}
|
||
|
ref = ref[len(defPrefix):]
|
||
|
// decode the json pointer encodings
|
||
|
ref = strings.Replace(ref, "~1", "/", -1)
|
||
|
ref = strings.Replace(ref, "~0", "~", -1)
|
||
|
nameParts := strings.SplitN(ref, "~", 2)
|
||
|
|
||
|
if len(nameParts) == 1 {
|
||
|
// local reference
|
||
|
return nameParts[0], "", nil
|
||
|
}
|
||
|
// cross-package reference
|
||
|
return nameParts[1], nameParts[0], nil
|
||
|
}
|
||
|
|
||
|
// identFromRef converts the given schema ref from the given package back
|
||
|
// into the TypeIdent that it represents.
|
||
|
func identFromRef(ref string, contextPkg *loader.Package) (TypeIdent, error) {
|
||
|
typ, pkgName, err := RefParts(ref)
|
||
|
if err != nil {
|
||
|
return TypeIdent{}, err
|
||
|
}
|
||
|
|
||
|
if pkgName == "" {
|
||
|
// a local reference
|
||
|
return TypeIdent{
|
||
|
Name: typ,
|
||
|
Package: contextPkg,
|
||
|
}, nil
|
||
|
}
|
||
|
|
||
|
// an external reference
|
||
|
return TypeIdent{
|
||
|
Name: typ,
|
||
|
Package: contextPkg.Imports()[pkgName],
|
||
|
}, nil
|
||
|
}
|
||
|
|
||
|
// preserveFields copies documentation fields from src into dst, preserving
|
||
|
// field-level documentation when flattening, and preserving field-level validation
|
||
|
// as allOf entries.
|
||
|
func preserveFields(dst *apiext.JSONSchemaProps, src apiext.JSONSchemaProps) {
|
||
|
srcDesc := src.Description
|
||
|
srcTitle := src.Title
|
||
|
srcExDoc := src.ExternalDocs
|
||
|
srcEx := src.Example
|
||
|
|
||
|
src.Description, src.Title, src.ExternalDocs, src.Example = "", "", nil, nil
|
||
|
|
||
|
src.Ref = nil
|
||
|
*dst = apiext.JSONSchemaProps{
|
||
|
AllOf: []apiext.JSONSchemaProps{*dst, src},
|
||
|
|
||
|
// keep these, in case the source field doesn't specify anything useful
|
||
|
Description: dst.Description,
|
||
|
Title: dst.Title,
|
||
|
ExternalDocs: dst.ExternalDocs,
|
||
|
Example: dst.Example,
|
||
|
}
|
||
|
|
||
|
if srcDesc != "" {
|
||
|
dst.Description = srcDesc
|
||
|
}
|
||
|
if srcTitle != "" {
|
||
|
dst.Title = srcTitle
|
||
|
}
|
||
|
if srcExDoc != nil {
|
||
|
dst.ExternalDocs = srcExDoc
|
||
|
}
|
||
|
if srcEx != nil {
|
||
|
dst.Example = srcEx
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// flattenVisitor visits each node in the schema, recursively flattening references.
|
||
|
type flattenVisitor struct {
|
||
|
*Flattener
|
||
|
|
||
|
currentPackage *loader.Package
|
||
|
currentType *TypeIdent
|
||
|
currentSchema *apiext.JSONSchemaProps
|
||
|
originalField apiext.JSONSchemaProps
|
||
|
}
|
||
|
|
||
|
func (f *flattenVisitor) Visit(baseSchema *apiext.JSONSchemaProps) SchemaVisitor {
|
||
|
if baseSchema == nil {
|
||
|
// end-of-node marker, cache the results
|
||
|
if f.currentType != nil {
|
||
|
f.cacheType(*f.currentType, *f.currentSchema)
|
||
|
// preserve field information *after* caching so that we don't
|
||
|
// accidentally cache field-level information onto the schema for
|
||
|
// the type in general.
|
||
|
preserveFields(f.currentSchema, f.originalField)
|
||
|
}
|
||
|
return f
|
||
|
}
|
||
|
|
||
|
// if we get a type that's just a ref, resolve it
|
||
|
if baseSchema.Ref != nil && len(*baseSchema.Ref) > 0 {
|
||
|
// resolve this ref
|
||
|
refIdent, err := f.LookupReference(*baseSchema.Ref, f.currentPackage)
|
||
|
if err != nil {
|
||
|
f.currentPackage.AddError(err)
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
// load and potentially flatten the schema
|
||
|
|
||
|
// check the cache first...
|
||
|
if refSchemaCached, isCached := f.flattenedTypes[refIdent]; isCached {
|
||
|
// shallow copy is fine, it's just to avoid overwriting the doc fields
|
||
|
preserveFields(&refSchemaCached, *baseSchema)
|
||
|
*baseSchema = refSchemaCached
|
||
|
return nil // don't recurse, we're done
|
||
|
}
|
||
|
|
||
|
// ...otherwise, we need to flatten
|
||
|
refSchema, err := f.loadUnflattenedSchema(refIdent)
|
||
|
if err != nil {
|
||
|
f.currentPackage.AddError(err)
|
||
|
return nil
|
||
|
}
|
||
|
refSchema = refSchema.DeepCopy()
|
||
|
|
||
|
// keep field around to preserve field-level validation, docs, etc
|
||
|
origField := *baseSchema
|
||
|
*baseSchema = *refSchema
|
||
|
|
||
|
// avoid loops (which shouldn't exist, but just in case)
|
||
|
// by marking a nil cached pointer before we start recursing
|
||
|
f.cacheType(refIdent, apiext.JSONSchemaProps{})
|
||
|
|
||
|
return &flattenVisitor{
|
||
|
Flattener: f.Flattener,
|
||
|
|
||
|
currentPackage: refIdent.Package,
|
||
|
currentType: &refIdent,
|
||
|
currentSchema: baseSchema,
|
||
|
originalField: origField,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// otherwise, continue recursing...
|
||
|
if f.currentType != nil {
|
||
|
// ...but don't accidentally end this node early (for caching purposes)
|
||
|
return &flattenVisitor{
|
||
|
Flattener: f.Flattener,
|
||
|
currentPackage: f.currentPackage,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return f
|
||
|
}
|