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