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 }