github.com/cilium/controller-tools@v0.3.1-0.20230329170030-f2b7ff866fde/pkg/crd/flatten.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 "reflect" 22 "sort" 23 "strings" 24 "sync" 25 26 apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 27 28 "sigs.k8s.io/controller-tools/pkg/loader" 29 ) 30 31 // ErrorRecorder knows how to record errors. It wraps the part of 32 // pkg/loader.Package that we need to record errors in places were it might not 33 // make sense to have a loader.Package 34 type ErrorRecorder interface { 35 // AddError records that the given error occurred. 36 // See the documentation on loader.Package.AddError for more information. 37 AddError(error) 38 } 39 40 // isOrNil checks if val is nil if val is of a nillable type, otherwise, 41 // it compares val to valInt (which should probably be the zero value). 42 func isOrNil(val reflect.Value, valInt interface{}, zeroInt interface{}) bool { 43 switch valKind := val.Kind(); valKind { 44 case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: 45 return val.IsNil() 46 default: 47 return valInt == zeroInt 48 } 49 } 50 51 // flattenAllOfInto copies properties from src to dst, then copies the properties 52 // of each item in src's allOf to dst's properties as well. 53 func flattenAllOfInto(dst *apiext.JSONSchemaProps, src apiext.JSONSchemaProps, errRec ErrorRecorder) { 54 if len(src.AllOf) > 0 { 55 for _, embedded := range src.AllOf { 56 flattenAllOfInto(dst, embedded, errRec) 57 } 58 } 59 60 dstVal := reflect.Indirect(reflect.ValueOf(dst)) 61 srcVal := reflect.ValueOf(src) 62 typ := dstVal.Type() 63 64 srcRemainder := apiext.JSONSchemaProps{} 65 srcRemVal := reflect.Indirect(reflect.ValueOf(&srcRemainder)) 66 dstRemainder := apiext.JSONSchemaProps{} 67 dstRemVal := reflect.Indirect(reflect.ValueOf(&dstRemainder)) 68 hoisted := false 69 70 for i := 0; i < srcVal.NumField(); i++ { 71 fieldName := typ.Field(i).Name 72 switch fieldName { 73 case "AllOf": 74 // don't merge because we deal with it above 75 continue 76 case "Title", "Description", "Example", "ExternalDocs": 77 // don't merge because we pre-merge to properly preserve field docs 78 continue 79 case "XPreserveUnknownFields": 80 // don't merge because we should let the dst set it if it wants 81 // this field 82 continue 83 } 84 srcField := srcVal.Field(i) 85 fldTyp := srcField.Type() 86 zeroVal := reflect.Zero(fldTyp) 87 zeroInt := zeroVal.Interface() 88 srcInt := srcField.Interface() 89 90 if isOrNil(srcField, srcInt, zeroInt) { 91 // nothing to copy from src, continue 92 continue 93 } 94 95 dstField := dstVal.Field(i) 96 dstInt := dstField.Interface() 97 if isOrNil(dstField, dstInt, zeroInt) { 98 // dst is empty, continue 99 dstField.Set(srcField) 100 continue 101 } 102 103 if fldTyp.Comparable() && srcInt == dstInt { 104 // same value, continue 105 continue 106 } 107 108 // resolve conflict 109 switch fieldName { 110 case "Properties": 111 // merge if possible, use all of otherwise 112 srcMap := srcInt.(map[string]apiext.JSONSchemaProps) 113 dstMap := dstInt.(map[string]apiext.JSONSchemaProps) 114 115 for k, v := range srcMap { 116 dstProp, exists := dstMap[k] 117 if !exists { 118 dstMap[k] = v 119 continue 120 } 121 flattenAllOfInto(&dstProp, v, errRec) 122 dstMap[k] = dstProp 123 } 124 case "Required": 125 // merge 126 dstField.Set(reflect.AppendSlice(dstField, srcField)) 127 case "Type": 128 if srcInt != dstInt { 129 // TODO(directxman12): figure out how to attach this back to a useful point in the Go source or in the schema 130 errRec.AddError(fmt.Errorf("conflicting types in allOf branches in schema: %s vs %s", dstInt, srcInt)) 131 } 132 // keep the destination value, for now 133 // TODO(directxman12): Default -- use field? 134 // TODO(directxman12): 135 // - Dependencies: if field x is present, then either schema validates or all props are present 136 // - AdditionalItems: like AdditionalProperties 137 // - Definitions: common named validation sets that can be references (merge, bail if duplicate) 138 case "AdditionalProperties": 139 // as of the time of writing, `allows: false` is not allowed, so we don't have to handle it 140 srcProps := srcInt.(*apiext.JSONSchemaPropsOrBool) 141 if srcProps.Schema == nil { 142 // nothing to merge 143 continue 144 } 145 dstProps := dstInt.(*apiext.JSONSchemaPropsOrBool) 146 if dstProps.Schema == nil { 147 dstProps.Schema = &apiext.JSONSchemaProps{} 148 } 149 flattenAllOfInto(dstProps.Schema, *srcProps.Schema, errRec) 150 // NB(directxman12): no need to explicitly handle nullable -- false is considered to be the zero value 151 // TODO(directxman12): src isn't necessarily the field value -- it's just the most recent allOf entry 152 default: 153 // hoist into allOf... 154 hoisted = true 155 156 srcRemVal.Field(i).Set(srcField) 157 dstRemVal.Field(i).Set(dstField) 158 // ...and clear the original 159 dstField.Set(zeroVal) 160 } 161 } 162 163 if hoisted { 164 dst.AllOf = append(dst.AllOf, dstRemainder, srcRemainder) 165 } 166 167 // dedup required 168 if len(dst.Required) > 0 { 169 reqUniq := make(map[string]struct{}) 170 for _, req := range dst.Required { 171 reqUniq[req] = struct{}{} 172 } 173 dst.Required = make([]string, 0, len(reqUniq)) 174 for req := range reqUniq { 175 dst.Required = append(dst.Required, req) 176 } 177 // be deterministic 178 sort.Strings(dst.Required) 179 } 180 } 181 182 // allOfVisitor recursively visits allOf fields in the schema, 183 // merging nested allOf properties into the root schema. 184 type allOfVisitor struct { 185 // errRec is used to record errors while flattening (like two conflicting 186 // field values used in an allOf) 187 errRec ErrorRecorder 188 } 189 190 func (v *allOfVisitor) Visit(schema *apiext.JSONSchemaProps) SchemaVisitor { 191 if schema == nil { 192 return v 193 } 194 195 // clear this now so that we can safely preserve edits made my flattenAllOfInto 196 origAllOf := schema.AllOf 197 schema.AllOf = nil 198 199 for _, embedded := range origAllOf { 200 flattenAllOfInto(schema, embedded, v.errRec) 201 } 202 return v 203 } 204 205 // NB(directxman12): FlattenEmbedded is separate from Flattener because 206 // some tooling wants to flatten out embedded fields, but only actually 207 // flatten a few specific types first. 208 209 // FlattenEmbedded flattens embedded fields (represented via AllOf) which have 210 // already had their references resolved into simple properties in the containing 211 // schema. 212 func FlattenEmbedded(schema *apiext.JSONSchemaProps, errRec ErrorRecorder) *apiext.JSONSchemaProps { 213 outSchema := schema.DeepCopy() 214 EditSchema(outSchema, &allOfVisitor{errRec: errRec}) 215 return outSchema 216 } 217 218 // Flattener knows how to take a root type, and flatten all references in it 219 // into a single, flat type. Flattened types are cached, so it's relatively 220 // cheap to make repeated calls with the same type. 221 type Flattener struct { 222 // Parser is used to lookup package and type details, and parse in new packages. 223 Parser *Parser 224 225 LookupReference func(ref string, contextPkg *loader.Package) (TypeIdent, error) 226 227 // flattenedTypes hold the flattened version of each seen type for later reuse. 228 flattenedTypes map[TypeIdent]apiext.JSONSchemaProps 229 initOnce sync.Once 230 } 231 232 func (f *Flattener) init() { 233 f.initOnce.Do(func() { 234 f.flattenedTypes = make(map[TypeIdent]apiext.JSONSchemaProps) 235 if f.LookupReference == nil { 236 f.LookupReference = identFromRef 237 } 238 }) 239 } 240 241 // cacheType saves the flattened version of the given type for later reuse 242 func (f *Flattener) cacheType(typ TypeIdent, schema apiext.JSONSchemaProps) { 243 f.init() 244 f.flattenedTypes[typ] = schema 245 } 246 247 // loadUnflattenedSchema fetches a fresh, unflattened schema from the parser. 248 func (f *Flattener) loadUnflattenedSchema(typ TypeIdent) (*apiext.JSONSchemaProps, error) { 249 f.Parser.NeedSchemaFor(typ) 250 251 baseSchema, found := f.Parser.Schemata[typ] 252 if !found { 253 return nil, fmt.Errorf("unable to locate schema for type %s", typ) 254 } 255 return &baseSchema, nil 256 } 257 258 // FlattenType flattens the given pre-loaded type, removing any references from it. 259 // It deep-copies the schema first, so it won't affect the parser's version of the schema. 260 func (f *Flattener) FlattenType(typ TypeIdent) *apiext.JSONSchemaProps { 261 f.init() 262 if cachedSchema, isCached := f.flattenedTypes[typ]; isCached { 263 return &cachedSchema 264 } 265 baseSchema, err := f.loadUnflattenedSchema(typ) 266 if err != nil { 267 typ.Package.AddError(err) 268 return nil 269 } 270 resSchema := f.FlattenSchema(*baseSchema, typ.Package) 271 f.cacheType(typ, *resSchema) 272 return resSchema 273 } 274 275 // FlattenSchema flattens the given schema, removing any references. 276 // It deep-copies the schema first, so the input schema won't be affected. 277 func (f *Flattener) FlattenSchema(baseSchema apiext.JSONSchemaProps, currentPackage *loader.Package) *apiext.JSONSchemaProps { 278 resSchema := baseSchema.DeepCopy() 279 EditSchema(resSchema, &flattenVisitor{ 280 Flattener: f, 281 currentPackage: currentPackage, 282 }) 283 284 return resSchema 285 } 286 287 // RefParts splits a reference produced by the schema generator into its component 288 // type name and package name (if it's a cross-package reference). Note that 289 // referenced packages *must* be looked up relative to the current package. 290 func RefParts(ref string) (typ string, pkgName string, err error) { 291 if !strings.HasPrefix(ref, defPrefix) { 292 return "", "", fmt.Errorf("non-standard reference link %q", ref) 293 } 294 ref = ref[len(defPrefix):] 295 // decode the json pointer encodings 296 ref = strings.Replace(ref, "~1", "/", -1) 297 ref = strings.Replace(ref, "~0", "~", -1) 298 nameParts := strings.SplitN(ref, "~", 2) 299 300 if len(nameParts) == 1 { 301 // local reference 302 return nameParts[0], "", nil 303 } 304 // cross-package reference 305 return nameParts[1], nameParts[0], nil 306 } 307 308 // identFromRef converts the given schema ref from the given package back 309 // into the TypeIdent that it represents. 310 func identFromRef(ref string, contextPkg *loader.Package) (TypeIdent, error) { 311 typ, pkgName, err := RefParts(ref) 312 if err != nil { 313 return TypeIdent{}, err 314 } 315 316 if pkgName == "" { 317 // a local reference 318 return TypeIdent{ 319 Name: typ, 320 Package: contextPkg, 321 }, nil 322 } 323 324 // an external reference 325 return TypeIdent{ 326 Name: typ, 327 Package: contextPkg.Imports()[pkgName], 328 }, nil 329 } 330 331 // preserveFields copies documentation fields from src into dst, preserving 332 // field-level documentation when flattening, and preserving field-level validation 333 // as allOf entries. 334 func preserveFields(dst *apiext.JSONSchemaProps, src apiext.JSONSchemaProps) { 335 srcDesc := src.Description 336 srcTitle := src.Title 337 srcExDoc := src.ExternalDocs 338 srcEx := src.Example 339 340 src.Description, src.Title, src.ExternalDocs, src.Example = "", "", nil, nil 341 342 src.Ref = nil 343 *dst = apiext.JSONSchemaProps{ 344 AllOf: []apiext.JSONSchemaProps{*dst, src}, 345 346 // keep these, in case the source field doesn't specify anything useful 347 Description: dst.Description, 348 Title: dst.Title, 349 ExternalDocs: dst.ExternalDocs, 350 Example: dst.Example, 351 } 352 353 if srcDesc != "" { 354 dst.Description = srcDesc 355 } 356 if srcTitle != "" { 357 dst.Title = srcTitle 358 } 359 if srcExDoc != nil { 360 dst.ExternalDocs = srcExDoc 361 } 362 if srcEx != nil { 363 dst.Example = srcEx 364 } 365 } 366 367 // flattenVisitor visits each node in the schema, recursively flattening references. 368 type flattenVisitor struct { 369 *Flattener 370 371 currentPackage *loader.Package 372 currentType *TypeIdent 373 currentSchema *apiext.JSONSchemaProps 374 originalField apiext.JSONSchemaProps 375 } 376 377 func (f *flattenVisitor) Visit(baseSchema *apiext.JSONSchemaProps) SchemaVisitor { 378 if baseSchema == nil { 379 // end-of-node marker, cache the results 380 if f.currentType != nil { 381 f.cacheType(*f.currentType, *f.currentSchema) 382 // preserve field information *after* caching so that we don't 383 // accidentally cache field-level information onto the schema for 384 // the type in general. 385 preserveFields(f.currentSchema, f.originalField) 386 } 387 return f 388 } 389 390 // if we get a type that's just a ref, resolve it 391 if baseSchema.Ref != nil && len(*baseSchema.Ref) > 0 { 392 // resolve this ref 393 refIdent, err := f.LookupReference(*baseSchema.Ref, f.currentPackage) 394 if err != nil { 395 f.currentPackage.AddError(err) 396 return nil 397 } 398 399 // load and potentially flatten the schema 400 401 // check the cache first... 402 if refSchemaCached, isCached := f.flattenedTypes[refIdent]; isCached { 403 // shallow copy is fine, it's just to avoid overwriting the doc fields 404 preserveFields(&refSchemaCached, *baseSchema) 405 *baseSchema = refSchemaCached 406 return nil // don't recurse, we're done 407 } 408 409 // ...otherwise, we need to flatten 410 refSchema, err := f.loadUnflattenedSchema(refIdent) 411 if err != nil { 412 f.currentPackage.AddError(err) 413 return nil 414 } 415 refSchema = refSchema.DeepCopy() 416 417 // keep field around to preserve field-level validation, docs, etc 418 origField := *baseSchema 419 *baseSchema = *refSchema 420 421 // avoid loops (which shouldn't exist, but just in case) 422 // by marking a nil cached pointer before we start recursing 423 f.cacheType(refIdent, apiext.JSONSchemaProps{}) 424 425 return &flattenVisitor{ 426 Flattener: f.Flattener, 427 428 currentPackage: refIdent.Package, 429 currentType: &refIdent, 430 currentSchema: baseSchema, 431 originalField: origField, 432 } 433 } 434 435 // otherwise, continue recursing... 436 if f.currentType != nil { 437 // ...but don't accidentally end this node early (for caching purposes) 438 return &flattenVisitor{ 439 Flattener: f.Flattener, 440 currentPackage: f.currentPackage, 441 } 442 } 443 444 return f 445 }