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 }