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 }