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