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