github.com/TheSpiritXIII/controller-tools@v0.14.1/pkg/crd/schema.go (about)

     1  /*
     2  Copyright 2019 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package crd
    18  
    19  import (
    20  	"errors"
    21  	"fmt"
    22  	"go/ast"
    23  	"go/token"
    24  	"go/types"
    25  	"reflect"
    26  	"sort"
    27  	"strings"
    28  
    29  	apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
    30  	crdmarkers "github.com/TheSpiritXIII/controller-tools/pkg/crd/markers"
    31  
    32  	"github.com/TheSpiritXIII/controller-tools/pkg/loader"
    33  	"github.com/TheSpiritXIII/controller-tools/pkg/markers"
    34  )
    35  
    36  // Schema flattening is done in a recursive mapping method.
    37  // Start reading at infoToSchema.
    38  
    39  const (
    40  	// defPrefix is the prefix used to link to definitions in the OpenAPI schema.
    41  	defPrefix = "#/definitions/"
    42  )
    43  
    44  // byteType is the types.Type for byte (see the types documention
    45  // for why we need to look this up in the Universe), saved
    46  // for quick comparison.
    47  var byteType = types.Universe.Lookup("byte").Type()
    48  
    49  // SchemaMarker is any marker that needs to modify the schema of the underlying type or field.
    50  type SchemaMarker interface {
    51  	// ApplyToSchema is called after the rest of the schema for a given type
    52  	// or field is generated, to modify the schema appropriately.
    53  	ApplyToSchema(*apiext.JSONSchemaProps) error
    54  }
    55  
    56  // applyFirstMarker is applied before any other markers.  It's a bit of a hack.
    57  type applyFirstMarker interface {
    58  	ApplyFirst()
    59  }
    60  
    61  // schemaRequester knows how to marker that another schema (e.g. via an external reference) is necessary.
    62  type schemaRequester interface {
    63  	NeedSchemaFor(typ TypeIdent)
    64  }
    65  
    66  // schemaContext stores and provides information across a hierarchy of schema generation.
    67  type schemaContext struct {
    68  	pkg  *loader.Package
    69  	info *markers.TypeInfo
    70  
    71  	schemaRequester schemaRequester
    72  	PackageMarkers  markers.MarkerValues
    73  
    74  	allowDangerousTypes    bool
    75  	ignoreUnexportedFields bool
    76  }
    77  
    78  // newSchemaContext constructs a new schemaContext for the given package and schema requester.
    79  // It must have type info added before use via ForInfo.
    80  func newSchemaContext(pkg *loader.Package, req schemaRequester, allowDangerousTypes, ignoreUnexportedFields bool) *schemaContext {
    81  	pkg.NeedTypesInfo()
    82  	return &schemaContext{
    83  		pkg:                    pkg,
    84  		schemaRequester:        req,
    85  		allowDangerousTypes:    allowDangerousTypes,
    86  		ignoreUnexportedFields: ignoreUnexportedFields,
    87  	}
    88  }
    89  
    90  // ForInfo produces a new schemaContext with containing the same information
    91  // as this one, except with the given type information.
    92  func (c *schemaContext) ForInfo(info *markers.TypeInfo) *schemaContext {
    93  	return &schemaContext{
    94  		pkg:                    c.pkg,
    95  		info:                   info,
    96  		schemaRequester:        c.schemaRequester,
    97  		allowDangerousTypes:    c.allowDangerousTypes,
    98  		ignoreUnexportedFields: c.ignoreUnexportedFields,
    99  	}
   100  }
   101  
   102  // requestSchema asks for the schema for a type in the package with the
   103  // given import path.
   104  func (c *schemaContext) requestSchema(pkgPath, typeName string) {
   105  	pkg := c.pkg
   106  	if pkgPath != "" {
   107  		pkg = c.pkg.Imports()[pkgPath]
   108  	}
   109  	c.schemaRequester.NeedSchemaFor(TypeIdent{
   110  		Package: pkg,
   111  		Name:    typeName,
   112  	})
   113  }
   114  
   115  // infoToSchema creates a schema for the type in the given set of type information.
   116  func infoToSchema(ctx *schemaContext) *apiext.JSONSchemaProps {
   117  	// If the obj implements a JSON marshaler and has a marker, use the markers value and do not traverse as
   118  	// the marshaler could be doing anything. If there is no marker, fall back to traversing.
   119  	if obj := ctx.pkg.Types.Scope().Lookup(ctx.info.Name); obj != nil && implementsJSONMarshaler(obj.Type()) {
   120  		schema := &apiext.JSONSchemaProps{}
   121  		applyMarkers(ctx, ctx.info.Markers, schema, ctx.info.RawSpec.Type)
   122  		if schema.Type != "" {
   123  			return schema
   124  		}
   125  	}
   126  	return typeToSchema(ctx, ctx.info.RawSpec.Type)
   127  }
   128  
   129  // applyMarkers applies schema markers given their priority to the given schema
   130  func applyMarkers(ctx *schemaContext, markerSet markers.MarkerValues, props *apiext.JSONSchemaProps, node ast.Node) {
   131  	markers := make([]SchemaMarker, 0, len(markerSet))
   132  
   133  	for _, markerValues := range markerSet {
   134  		for _, markerValue := range markerValues {
   135  			if schemaMarker, isSchemaMarker := markerValue.(SchemaMarker); isSchemaMarker {
   136  				markers = append(markers, schemaMarker)
   137  			}
   138  		}
   139  	}
   140  
   141  	sort.Slice(markers, func(i, j int) bool {
   142  		var iPriority, jPriority crdmarkers.ApplyPriority
   143  
   144  		switch m := markers[i].(type) {
   145  		case crdmarkers.ApplyPriorityMarker:
   146  			iPriority = m.ApplyPriority()
   147  		case applyFirstMarker:
   148  			iPriority = crdmarkers.ApplyPriorityFirst
   149  		default:
   150  			iPriority = crdmarkers.ApplyPriorityDefault
   151  		}
   152  
   153  		switch m := markers[j].(type) {
   154  		case crdmarkers.ApplyPriorityMarker:
   155  			jPriority = m.ApplyPriority()
   156  		case applyFirstMarker:
   157  			jPriority = crdmarkers.ApplyPriorityFirst
   158  		default:
   159  			jPriority = crdmarkers.ApplyPriorityDefault
   160  		}
   161  
   162  		return iPriority < jPriority
   163  	})
   164  
   165  	for _, schemaMarker := range markers {
   166  		if err := schemaMarker.ApplyToSchema(props); err != nil {
   167  			ctx.pkg.AddError(loader.ErrFromNode(err /* an okay guess */, node))
   168  		}
   169  	}
   170  }
   171  
   172  // typeToSchema creates a schema for the given AST type.
   173  func typeToSchema(ctx *schemaContext, rawType ast.Expr) *apiext.JSONSchemaProps {
   174  	var props *apiext.JSONSchemaProps
   175  	switch expr := rawType.(type) {
   176  	case *ast.Ident:
   177  		props = localNamedToSchema(ctx, expr)
   178  	case *ast.SelectorExpr:
   179  		props = namedToSchema(ctx, expr)
   180  	case *ast.ArrayType:
   181  		props = arrayToSchema(ctx, expr)
   182  	case *ast.MapType:
   183  		props = mapToSchema(ctx, expr)
   184  	case *ast.StarExpr:
   185  		props = typeToSchema(ctx, expr.X)
   186  	case *ast.StructType:
   187  		props = structToSchema(ctx, expr)
   188  	default:
   189  		ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("unsupported AST kind %T", expr), rawType))
   190  		// NB(directxman12): we explicitly don't handle interfaces
   191  		return &apiext.JSONSchemaProps{}
   192  	}
   193  
   194  	props.Description = ctx.info.Doc
   195  
   196  	applyMarkers(ctx, ctx.info.Markers, props, rawType)
   197  
   198  	return props
   199  }
   200  
   201  // qualifiedName constructs a JSONSchema-safe qualified name for a type
   202  // (`<typeName>` or `<safePkgPath>~0<typeName>`, where `<safePkgPath>`
   203  // is the package path with `/` replaced by `~1`, according to JSONPointer
   204  // escapes).
   205  func qualifiedName(pkgName, typeName string) string {
   206  	if pkgName != "" {
   207  		return strings.Replace(pkgName, "/", "~1", -1) + "~0" + typeName
   208  	}
   209  	return typeName
   210  }
   211  
   212  // TypeRefLink creates a definition link for the given type and package.
   213  func TypeRefLink(pkgName, typeName string) string {
   214  	return defPrefix + qualifiedName(pkgName, typeName)
   215  }
   216  
   217  // localNamedToSchema creates a schema (ref) for a *potentially* local type reference
   218  // (could be external from a dot-import).
   219  func localNamedToSchema(ctx *schemaContext, ident *ast.Ident) *apiext.JSONSchemaProps {
   220  	typeInfo := ctx.pkg.TypesInfo.TypeOf(ident)
   221  	if typeInfo == types.Typ[types.Invalid] {
   222  		ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("unknown type %s", ident.Name), ident))
   223  		return &apiext.JSONSchemaProps{}
   224  	}
   225  	if basicInfo, isBasic := typeInfo.(*types.Basic); isBasic {
   226  		typ, fmt, err := builtinToType(basicInfo, ctx.allowDangerousTypes)
   227  		if err != nil {
   228  			ctx.pkg.AddError(loader.ErrFromNode(err, ident))
   229  		}
   230  		return &apiext.JSONSchemaProps{
   231  			Type:   typ,
   232  			Format: fmt,
   233  		}
   234  	}
   235  	// NB(directxman12): if there are dot imports, this might be an external reference,
   236  	// so use typechecking info to get the actual object
   237  	typeNameInfo := typeInfo.(*types.Named).Obj()
   238  	pkg := typeNameInfo.Pkg()
   239  	pkgPath := loader.NonVendorPath(pkg.Path())
   240  	if pkg == ctx.pkg.Types {
   241  		pkgPath = ""
   242  	}
   243  	ctx.requestSchema(pkgPath, typeNameInfo.Name())
   244  	link := TypeRefLink(pkgPath, typeNameInfo.Name())
   245  	return &apiext.JSONSchemaProps{
   246  		Ref: &link,
   247  	}
   248  }
   249  
   250  // namedSchema creates a schema (ref) for an explicitly external type reference.
   251  func namedToSchema(ctx *schemaContext, named *ast.SelectorExpr) *apiext.JSONSchemaProps {
   252  	typeInfoRaw := ctx.pkg.TypesInfo.TypeOf(named)
   253  	if typeInfoRaw == types.Typ[types.Invalid] {
   254  		ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("unknown type %v.%s", named.X, named.Sel.Name), named))
   255  		return &apiext.JSONSchemaProps{}
   256  	}
   257  	typeInfo := typeInfoRaw.(*types.Named)
   258  	typeNameInfo := typeInfo.Obj()
   259  	nonVendorPath := loader.NonVendorPath(typeNameInfo.Pkg().Path())
   260  	ctx.requestSchema(nonVendorPath, typeNameInfo.Name())
   261  	link := TypeRefLink(nonVendorPath, typeNameInfo.Name())
   262  	return &apiext.JSONSchemaProps{
   263  		Ref: &link,
   264  	}
   265  	// NB(directxman12): we special-case things like resource.Quantity during the "collapse" phase.
   266  }
   267  
   268  // arrayToSchema creates a schema for the items of the given array, dealing appropriately
   269  // with the special `[]byte` type (according to OpenAPI standards).
   270  func arrayToSchema(ctx *schemaContext, array *ast.ArrayType) *apiext.JSONSchemaProps {
   271  	eltType := ctx.pkg.TypesInfo.TypeOf(array.Elt)
   272  	if eltType == byteType && array.Len == nil {
   273  		// byte slices are represented as base64-encoded strings
   274  		// (the format is defined in OpenAPI v3, but not JSON Schema)
   275  		return &apiext.JSONSchemaProps{
   276  			Type:   "string",
   277  			Format: "byte",
   278  		}
   279  	}
   280  	// TODO(directxman12): backwards-compat would require access to markers from base info
   281  	items := typeToSchema(ctx.ForInfo(&markers.TypeInfo{}), array.Elt)
   282  
   283  	return &apiext.JSONSchemaProps{
   284  		Type:  "array",
   285  		Items: &apiext.JSONSchemaPropsOrArray{Schema: items},
   286  	}
   287  }
   288  
   289  // mapToSchema creates a schema for items of the given map.  Key types must eventually resolve
   290  // to string (other types aren't allowed by JSON, and thus the kubernetes API standards).
   291  func mapToSchema(ctx *schemaContext, mapType *ast.MapType) *apiext.JSONSchemaProps {
   292  	keyInfo := ctx.pkg.TypesInfo.TypeOf(mapType.Key)
   293  	// check that we've got a type that actually corresponds to a string
   294  	for keyInfo != nil {
   295  		switch typedKey := keyInfo.(type) {
   296  		case *types.Basic:
   297  			if typedKey.Info()&types.IsString == 0 {
   298  				ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("map keys must be strings, not %s", keyInfo.String()), mapType.Key))
   299  				return &apiext.JSONSchemaProps{}
   300  			}
   301  			keyInfo = nil // stop iterating
   302  		case *types.Named:
   303  			keyInfo = typedKey.Underlying()
   304  		default:
   305  			ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("map keys must be strings, not %s", keyInfo.String()), mapType.Key))
   306  			return &apiext.JSONSchemaProps{}
   307  		}
   308  	}
   309  
   310  	// TODO(directxman12): backwards-compat would require access to markers from base info
   311  	var valSchema *apiext.JSONSchemaProps
   312  	switch val := mapType.Value.(type) {
   313  	case *ast.Ident:
   314  		valSchema = localNamedToSchema(ctx.ForInfo(&markers.TypeInfo{}), val)
   315  	case *ast.SelectorExpr:
   316  		valSchema = namedToSchema(ctx.ForInfo(&markers.TypeInfo{}), val)
   317  	case *ast.ArrayType:
   318  		valSchema = arrayToSchema(ctx.ForInfo(&markers.TypeInfo{}), val)
   319  	case *ast.StarExpr:
   320  		valSchema = typeToSchema(ctx.ForInfo(&markers.TypeInfo{}), val)
   321  	case *ast.MapType:
   322  		valSchema = typeToSchema(ctx.ForInfo(&markers.TypeInfo{}), val)
   323  	default:
   324  		ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("not a supported map value type: %T", mapType.Value), mapType.Value))
   325  		return &apiext.JSONSchemaProps{}
   326  	}
   327  
   328  	return &apiext.JSONSchemaProps{
   329  		Type: "object",
   330  		AdditionalProperties: &apiext.JSONSchemaPropsOrBool{
   331  			Schema: valSchema,
   332  			Allows: true, /* set automatically by serialization, but useful for testing */
   333  		},
   334  	}
   335  }
   336  
   337  // fieldScopePropertyName is the name of the property used to sore field scope information. A more
   338  // appropriate solution would be to use a custom extension, but that's not possible yet.
   339  // See: https://github.com/kubernetes/kubernetes/issues/82942
   340  const fieldScopePropertyName = "x-kubebuilder-field-scopes"
   341  
   342  // structToSchema creates a schema for the given struct.  Embedded fields are placed in AllOf,
   343  // and can be flattened later with a Flattener.
   344  func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiext.JSONSchemaProps {
   345  	props := &apiext.JSONSchemaProps{
   346  		Type:       "object",
   347  		Properties: make(map[string]apiext.JSONSchemaProps),
   348  	}
   349  
   350  	if ctx.info.RawSpec.Type != structType {
   351  		ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("encountered non-top-level struct (possibly embedded), those aren't allowed"), structType))
   352  		return props
   353  	}
   354  
   355  	var clusterScopedFields, namespaceScopedFields []string
   356  	for _, field := range ctx.info.Fields {
   357  		// Skip if the field is not an inline field, ignoreUnexportedFields is true, and the field is not exported
   358  		if field.Name != "" && ctx.ignoreUnexportedFields && !ast.IsExported(field.Name) {
   359  			continue
   360  		}
   361  
   362  		jsonTag, hasTag := field.Tag.Lookup("json")
   363  		if !hasTag {
   364  			// if the field doesn't have a JSON tag, it doesn't belong in output (and shouldn't exist in a serialized type)
   365  			ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("encountered struct field %q without JSON tag in type %q", field.Name, ctx.info.Name), field.RawField))
   366  			continue
   367  		}
   368  		jsonOpts := strings.Split(jsonTag, ",")
   369  		if len(jsonOpts) == 1 && jsonOpts[0] == "-" {
   370  			// skipped fields have the tag "-" (note that "-," means the field is named "-")
   371  			continue
   372  		}
   373  
   374  		inline := false
   375  		omitEmpty := false
   376  		for _, opt := range jsonOpts[1:] {
   377  			switch opt {
   378  			case "inline":
   379  				inline = true
   380  			case "omitempty":
   381  				omitEmpty = true
   382  			}
   383  		}
   384  		fieldName := jsonOpts[0]
   385  		inline = inline || fieldName == "" // anonymous fields are inline fields in YAML/JSON
   386  
   387  		if scope := field.Markers.Get("kubebuilder:field:scope"); scope != nil {
   388  			value, ok := scope.(crdmarkers.FieldScope)
   389  			if !ok {
   390  				ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("encountered non-string struct %q field %q scope %s", ctx.info.Name, field.Name, reflect.ValueOf(scope).Type().Name()), field.RawField))
   391  				continue
   392  			}
   393  			var scope apiext.ResourceScope
   394  			switch value {
   395  			case "":
   396  				scope = apiext.NamespaceScoped
   397  			default:
   398  				scope = apiext.ResourceScope(value)
   399  			}
   400  			switch scope {
   401  			case apiext.ClusterScoped:
   402  				clusterScopedFields = append(clusterScopedFields, fieldName)
   403  			case apiext.NamespaceScoped:
   404  				namespaceScopedFields = append(namespaceScopedFields, fieldName)
   405  			default:
   406  				ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("invalid struct %q field %q scope %q", ctx.info.Name, field.Name, value), field.RawField))
   407  				continue
   408  			}
   409  		}
   410  
   411  		// if no default required mode is set, default to required
   412  		defaultMode := "required"
   413  		if ctx.PackageMarkers.Get("kubebuilder:validation:Optional") != nil {
   414  			defaultMode = "optional"
   415  		}
   416  
   417  		switch defaultMode {
   418  		// if this package isn't set to optional default...
   419  		case "required":
   420  			// ...everything that's not inline, omitempty, or explicitly optional is required
   421  			if !inline && !omitEmpty && field.Markers.Get("kubebuilder:validation:Optional") == nil && field.Markers.Get("optional") == nil {
   422  				props.Required = append(props.Required, fieldName)
   423  			}
   424  
   425  		// if this package isn't set to required default...
   426  		case "optional":
   427  			// ...everything that isn't explicitly required is optional
   428  			if field.Markers.Get("kubebuilder:validation:Required") != nil {
   429  				props.Required = append(props.Required, fieldName)
   430  			}
   431  		}
   432  
   433  		var propSchema *apiext.JSONSchemaProps
   434  		if field.Markers.Get(crdmarkers.SchemalessName) != nil {
   435  			propSchema = &apiext.JSONSchemaProps{}
   436  		} else {
   437  			propSchema = typeToSchema(ctx.ForInfo(&markers.TypeInfo{}), field.RawField.Type)
   438  		}
   439  		propSchema.Description = field.Doc
   440  
   441  		applyMarkers(ctx, field.Markers, propSchema, field.RawField)
   442  
   443  		if inline {
   444  			props.AllOf = append(props.AllOf, *propSchema)
   445  			continue
   446  		}
   447  
   448  		props.Properties[fieldName] = *propSchema
   449  	}
   450  
   451  	if len(clusterScopedFields) > 0 || len(namespaceScopedFields) > 0 {
   452  		props.Properties[fieldScopePropertyName] = apiext.JSONSchemaProps{
   453  			Type: "object",
   454  			Properties: map[string]apiext.JSONSchemaProps{
   455  				string(apiext.ClusterScoped): {
   456  					Type:     "object",
   457  					Required: clusterScopedFields,
   458  				},
   459  				string(apiext.NamespaceScoped): {
   460  					Type:     "object",
   461  					Required: namespaceScopedFields,
   462  				},
   463  			},
   464  		}
   465  	}
   466  
   467  	return props
   468  }
   469  
   470  // builtinToType converts builtin basic types to their equivalent JSON schema form.
   471  // It *only* handles types allowed by the kubernetes API standards. Floats are not
   472  // allowed unless allowDangerousTypes is true
   473  func builtinToType(basic *types.Basic, allowDangerousTypes bool) (typ string, format string, err error) {
   474  	// NB(directxman12): formats from OpenAPI v3 are slightly different than those defined
   475  	// in JSONSchema.  This'll use the OpenAPI v3 ones, since they're useful for bounding our
   476  	// non-string types.
   477  	basicInfo := basic.Info()
   478  	switch {
   479  	case basicInfo&types.IsBoolean != 0:
   480  		typ = "boolean"
   481  	case basicInfo&types.IsString != 0:
   482  		typ = "string"
   483  	case basicInfo&types.IsInteger != 0:
   484  		typ = "integer"
   485  	case basicInfo&types.IsFloat != 0:
   486  		if allowDangerousTypes {
   487  			typ = "number"
   488  		} else {
   489  			return "", "", errors.New("found float, the usage of which is highly discouraged, as support for them varies across languages. Please consider serializing your float as string instead. If you are really sure you want to use them, re-run with crd:allowDangerousTypes=true")
   490  		}
   491  	default:
   492  		return "", "", fmt.Errorf("unsupported type %q", basic.String())
   493  	}
   494  
   495  	switch basic.Kind() {
   496  	case types.Int32, types.Uint32:
   497  		format = "int32"
   498  	case types.Int64, types.Uint64:
   499  		format = "int64"
   500  	}
   501  
   502  	return typ, format, nil
   503  }
   504  
   505  // Open coded go/types representation of encoding/json.Marshaller
   506  var jsonMarshaler = types.NewInterfaceType([]*types.Func{
   507  	types.NewFunc(token.NoPos, nil, "MarshalJSON",
   508  		types.NewSignature(nil, nil,
   509  			types.NewTuple(
   510  				types.NewVar(token.NoPos, nil, "", types.NewSlice(types.Universe.Lookup("byte").Type())),
   511  				types.NewVar(token.NoPos, nil, "", types.Universe.Lookup("error").Type())), false)),
   512  }, nil).Complete()
   513  
   514  func implementsJSONMarshaler(typ types.Type) bool {
   515  	return types.Implements(typ, jsonMarshaler) || types.Implements(types.NewPointer(typ), jsonMarshaler)
   516  }