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 }