github.com/waynz0r/controller-tools@v0.4.1-0.20200916220028-16254aeef2d7/pkg/schemapatcher/gen.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 schemapatcher 18 19 import ( 20 "fmt" 21 "io/ioutil" 22 "path/filepath" 23 24 "gopkg.in/yaml.v3" 25 apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 26 apiextlegacy "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" 27 "k8s.io/apimachinery/pkg/api/equality" 28 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 29 "k8s.io/apimachinery/pkg/runtime/schema" 30 kyaml "sigs.k8s.io/yaml" 31 32 crdgen "sigs.k8s.io/controller-tools/pkg/crd" 33 crdmarkers "sigs.k8s.io/controller-tools/pkg/crd/markers" 34 "sigs.k8s.io/controller-tools/pkg/genall" 35 "sigs.k8s.io/controller-tools/pkg/markers" 36 yamlop "sigs.k8s.io/controller-tools/pkg/schemapatcher/internal/yaml" 37 ) 38 39 // NB(directxman12): this code is quite fragile, but there are a sufficient 40 // number of corner cases that it's hard to decompose into separate tools. 41 // When in doubt, ping @sttts. 42 // 43 // Namely: 44 // - It needs to only update existing versions 45 // - It needs to make "stable" changes that don't mess with map key ordering 46 // (in order to facilitate validating that no change has occurred) 47 // - It needs to collapse identical schema versions into a top-level schema, 48 // if all versions are identical (this is a common requirement to all CRDs, 49 // but in this case it means simple jsonpatch wouldn't suffice) 50 51 // TODO(directxman12): When CRD v1 rolls around, consider splitting this into a 52 // tool that generates a patch, and a separate tool for applying stable YAML 53 // patches. 54 55 var ( 56 legacyAPIExtVersion = apiextlegacy.SchemeGroupVersion.String() 57 currentAPIExtVersion = apiext.SchemeGroupVersion.String() 58 ) 59 60 // +controllertools:marker:generateHelp 61 62 // Generator patches existing CRDs with new schemata. 63 // 64 // For legacy (v1beta1) single-version CRDs, it will simply replace the global schema. 65 // 66 // For legacy (v1beta1) multi-version CRDs, and any v1 CRDs, it will replace 67 // schemata of existing versions and *clear the schema* from any versions not 68 // specified in the Go code. It will *not* add new versions, or remove old 69 // ones. 70 // 71 // For legacy multi-version CRDs with identical schemata, it will take care of 72 // lifting the per-version schema up to the global schema. 73 // 74 // It will generate output for each "CRD Version" (API version of the CRD type 75 // itself) , e.g. apiextensions/v1beta1 and apiextensions/v1) available. 76 type Generator struct { 77 // ManifestsPath contains the CustomResourceDefinition YAML files. 78 ManifestsPath string `marker:"manifests"` 79 80 // MaxDescLen specifies the maximum description length for fields in CRD's OpenAPI schema. 81 // 82 // 0 indicates drop the description for all fields completely. 83 // n indicates limit the description to at most n characters and truncate the description to 84 // closest sentence boundary if it exceeds n characters. 85 MaxDescLen *int `marker:",optional"` 86 } 87 88 var _ genall.Generator = &Generator{} 89 90 func (Generator) RegisterMarkers(into *markers.Registry) error { 91 return crdmarkers.Register(into) 92 } 93 94 func (g Generator) Generate(ctx *genall.GenerationContext) (result error) { 95 parser := &crdgen.Parser{ 96 Collector: ctx.Collector, 97 Checker: ctx.Checker, 98 } 99 100 crdgen.AddKnownTypes(parser) 101 for _, root := range ctx.Roots { 102 parser.NeedPackage(root) 103 } 104 105 metav1Pkg := crdgen.FindMetav1(ctx.Roots) 106 if metav1Pkg == nil { 107 // no objects in the roots, since nothing imported metav1 108 return nil 109 } 110 111 // load existing CRD manifests with group-kind and versions 112 partialCRDSets, err := crdsFromDirectory(ctx, g.ManifestsPath) 113 if err != nil { 114 return err 115 } 116 117 // generate schemata for the types we care about, and save them to be written later. 118 for groupKind := range crdgen.FindKubeKinds(parser, metav1Pkg) { 119 existingSet, wanted := partialCRDSets[groupKind] 120 if !wanted { 121 continue 122 } 123 124 for pkg, gv := range parser.GroupVersions { 125 if gv.Group != groupKind.Group { 126 continue 127 } 128 if _, wantedVersion := existingSet.Versions[gv.Version]; !wantedVersion { 129 continue 130 } 131 132 typeIdent := crdgen.TypeIdent{Package: pkg, Name: groupKind.Kind} 133 parser.NeedFlattenedSchemaFor(typeIdent) 134 135 fullSchema := parser.FlattenedSchemata[typeIdent] 136 if g.MaxDescLen != nil { 137 fullSchema = *fullSchema.DeepCopy() 138 crdgen.TruncateDescription(&fullSchema, *g.MaxDescLen) 139 } 140 existingSet.NewSchemata[gv.Version] = fullSchema 141 } 142 } 143 144 // patch existing CRDs with new schemata 145 for _, existingSet := range partialCRDSets { 146 // first, figure out if we need to merge schemata together if they're *all* 147 // identical (meaning we also don't have any "unset" versions) 148 149 if len(existingSet.NewSchemata) == 0 { 150 continue 151 } 152 153 // copy over the new versions that we have, keeping old versions so 154 // that we can tell if a schema would be nil 155 var someVer string 156 for ver := range existingSet.NewSchemata { 157 someVer = ver 158 existingSet.Versions[ver] = struct{}{} 159 } 160 161 allSame := true 162 firstSchema := existingSet.NewSchemata[someVer] 163 for ver := range existingSet.Versions { 164 otherSchema, hasSchema := existingSet.NewSchemata[ver] 165 if !hasSchema || !equality.Semantic.DeepEqual(firstSchema, otherSchema) { 166 allSame = false 167 break 168 } 169 } 170 171 if allSame { 172 if err := existingSet.setGlobalSchema(); err != nil { 173 return fmt.Errorf("failed to set global firstSchema for %s: %w", existingSet.GroupKind, err) 174 } 175 } else { 176 if err := existingSet.setVersionedSchemata(); err != nil { 177 return fmt.Errorf("failed to set versioned schemas for %s: %w", existingSet.GroupKind, err) 178 } 179 } 180 } 181 182 // write the final result out to the new location 183 for _, set := range partialCRDSets { 184 // We assume all CRD versions came from different files, since this 185 // is how controller-gen works. If they came from the same file, 186 // it'd be non-sensical, since you couldn't reasonably use kubectl 187 // with them against older servers. 188 for _, crd := range set.CRDVersions { 189 if err := func() error { 190 outWriter, err := ctx.OutputRule.Open(nil, crd.FileName) 191 if err != nil { 192 return err 193 } 194 defer outWriter.Close() 195 196 enc := yaml.NewEncoder(outWriter) 197 // yaml.v2 defaults to indent=2, yaml.v3 defaults to indent=4, 198 // so be compatible with everything else in k8s and choose 2. 199 enc.SetIndent(2) 200 201 return enc.Encode(crd.Yaml) 202 }(); err != nil { 203 return err 204 } 205 } 206 } 207 208 return nil 209 } 210 211 // partialCRDSet represents a set of CRDs of different apiext versions 212 // (v1beta1.CRD vs v1.CRD) that represent the same GroupKind. 213 // 214 // It tracks modifications to the schemata of those CRDs from this source file, 215 // plus some useful structured content, and keeps track of the raw YAML representation 216 // of the different apiext versions. 217 type partialCRDSet struct { 218 // GroupKind is the GroupKind represented by this CRD. 219 GroupKind schema.GroupKind 220 // NewSchemata are the new schemata generated from Go IDL by controller-gen. 221 NewSchemata map[string]apiext.JSONSchemaProps 222 // CRDVersions are the forms of this CRD across different apiextensions 223 // versions 224 CRDVersions []*partialCRD 225 // Versions are the versions of the given GroupKind in this set of CRDs. 226 Versions map[string]struct{} 227 } 228 229 // partialCRD represents the raw YAML encoding of a given CRD instance, plus 230 // the versions contained therein for easy lookup. 231 type partialCRD struct { 232 // Yaml is the raw YAML structure of the CRD. 233 Yaml *yaml.Node 234 // FileName is the source name of the file that this was read from. 235 // 236 // This isn't on partialCRDSet because we could have different CRD versions 237 // stored in the same file (like controller-tools does by default) or in 238 // different files. 239 FileName string 240 241 // CRDVersion is the version of the CRD object itself, from 242 // apiextensions (currently apiextensions/v1 or apiextensions/v1beta1). 243 CRDVersion string 244 } 245 246 // setGlobalSchema sets the global schema for the v1beta1 apiext version in 247 // this set (if present, as per partialCRD.setGlobalSchema), and sets the 248 // versioned schemas (as per setVersionedSchemata) for the v1 version. 249 func (e *partialCRDSet) setGlobalSchema() error { 250 // there's no easy way to get a "random" key from a go map :-/ 251 var schema apiext.JSONSchemaProps 252 for ver := range e.NewSchemata { 253 schema = e.NewSchemata[ver] 254 break 255 } 256 for _, crdInfo := range e.CRDVersions { 257 switch crdInfo.CRDVersion { 258 case legacyAPIExtVersion: 259 if err := crdInfo.setGlobalSchema(schema); err != nil { 260 return err 261 } 262 case currentAPIExtVersion: 263 // just set the schemata as normal for non-legacy versions 264 if err := crdInfo.setVersionedSchemata(e.NewSchemata); err != nil { 265 return err 266 } 267 } 268 } 269 return nil 270 } 271 272 // setGlobalSchema sets the global schema to one of the schemata 273 // for this CRD. All schemata must be identical for this to be a valid operation. 274 func (e *partialCRD) setGlobalSchema(newSchema apiext.JSONSchemaProps) error { 275 if e.CRDVersion != legacyAPIExtVersion { 276 // no global schema, nothing to do 277 return fmt.Errorf("cannot set global schema on non-legacy CRD versions") 278 } 279 schema, err := legacySchema(newSchema) 280 if err != nil { 281 return fmt.Errorf("failed to convert schema to legacy form: %w", err) 282 } 283 schemaNodeTree, err := yamlop.ToYAML(schema) 284 if err != nil { 285 return err 286 } 287 schemaNodeTree = schemaNodeTree.Content[0] // get rid of the document node 288 yamlop.SetStyle(schemaNodeTree, 0) // clear the style so it defaults to auto-style-choice 289 290 if err := yamlop.SetNode(e.Yaml, *schemaNodeTree, "spec", "validation", "openAPIV3Schema"); err != nil { 291 return err 292 } 293 294 versions, found, err := e.getVersionsNode() 295 if err != nil { 296 return err 297 } 298 if !found { 299 return nil 300 } 301 for i, verNode := range versions.Content { 302 if err := yamlop.DeleteNode(verNode, "schema"); err != nil { 303 return fmt.Errorf("spec.versions[%d]: %w", i, err) 304 } 305 } 306 307 return nil 308 } 309 310 // getVersionsNode gets the YAML node of .spec.versions YAML mapping, 311 // if returning the node, and whether or not it was present. 312 func (e *partialCRD) getVersionsNode() (*yaml.Node, bool, error) { 313 versions, found, err := yamlop.GetNode(e.Yaml, "spec", "versions") 314 if err != nil { 315 return nil, false, err 316 } 317 if !found { 318 return nil, false, nil 319 } 320 if versions.Kind != yaml.SequenceNode { 321 return nil, true, fmt.Errorf("unexpected non-sequence versions") 322 } 323 return versions, found, nil 324 } 325 326 // setVersionedSchemata sets the versioned schemata on each encoding in this set as per 327 // setVersionedSchemata on partialCRD. 328 func (e *partialCRDSet) setVersionedSchemata() error { 329 for _, crdInfo := range e.CRDVersions { 330 if err := crdInfo.setVersionedSchemata(e.NewSchemata); err != nil { 331 return err 332 } 333 } 334 return nil 335 } 336 337 // setVersionedSchemata populates all existing versions with new schemata, 338 // wiping the schema of any version that doesn't have a listed schema. 339 // Any "unknown" versions are ignored. 340 func (e *partialCRD) setVersionedSchemata(newSchemata map[string]apiext.JSONSchemaProps) error { 341 var err error 342 if err := yamlop.DeleteNode(e.Yaml, "spec", "validation"); err != nil { 343 return err 344 } 345 346 versions, found, err := e.getVersionsNode() 347 if err != nil { 348 return err 349 } 350 if !found { 351 return fmt.Errorf("unexpected missing versions") 352 } 353 354 for i, verNode := range versions.Content { 355 nameNode, _, _ := yamlop.GetNode(verNode, "name") 356 if nameNode.Kind != yaml.ScalarNode || nameNode.ShortTag() != "!!str" { 357 return fmt.Errorf("version name was not a string at spec.versions[%d]", i) 358 } 359 name := nameNode.Value 360 if name == "" { 361 return fmt.Errorf("unexpected empty name at spec.versions[%d]", i) 362 } 363 newSchema, found := newSchemata[name] 364 if !found { 365 if err := yamlop.DeleteNode(verNode, "schema"); err != nil { 366 return fmt.Errorf("spec.versions[%d]: %w", i, err) 367 } 368 } else { 369 // TODO(directxman12): if this gets to be more than 2 versions, use polymorphism to clean this up 370 var verSchema interface{} = newSchema 371 if e.CRDVersion == legacyAPIExtVersion { 372 verSchema, err = legacySchema(newSchema) 373 if err != nil { 374 return fmt.Errorf("failed to convert schema to legacy form: %w", err) 375 } 376 } 377 378 schemaNodeTree, err := yamlop.ToYAML(verSchema) 379 if err != nil { 380 return fmt.Errorf("failed to convert schema to YAML: %w", err) 381 } 382 schemaNodeTree = schemaNodeTree.Content[0] // get rid of the document node 383 yamlop.SetStyle(schemaNodeTree, 0) // clear the style so it defaults to an auto-chosen one 384 if err := yamlop.SetNode(verNode, *schemaNodeTree, "schema", "openAPIV3Schema"); err != nil { 385 return fmt.Errorf("spec.versions[%d]: %w", i, err) 386 } 387 } 388 } 389 return nil 390 } 391 392 // crdsFromDirectory returns loads all CRDs from the given directory in a 393 // manner that preserves ordering, comments, etc in order to make patching 394 // minimally invasive. Returned CRDs are mapped by group-kind. 395 func crdsFromDirectory(ctx *genall.GenerationContext, dir string) (map[schema.GroupKind]*partialCRDSet, error) { 396 res := map[schema.GroupKind]*partialCRDSet{} 397 dirEntries, err := ioutil.ReadDir(dir) 398 if err != nil { 399 return nil, err 400 } 401 for _, fileInfo := range dirEntries { 402 // find all files that are YAML 403 if fileInfo.IsDir() || filepath.Ext(fileInfo.Name()) != ".yaml" { 404 continue 405 } 406 407 rawContent, err := ctx.ReadFile(filepath.Join(dir, fileInfo.Name())) 408 if err != nil { 409 return nil, err 410 } 411 412 // NB(directxman12): we could use the universal deserializer for this, but it's 413 // really pretty clunky, and the alternative is actually kinda easier to understand 414 415 // ensure that this is a CRD 416 var typeMeta metav1.TypeMeta 417 if err := kyaml.Unmarshal(rawContent, &typeMeta); err != nil { 418 continue 419 } 420 if !isSupportedAPIExtGroupVer(typeMeta.APIVersion) || typeMeta.Kind != "CustomResourceDefinition" { 421 continue 422 } 423 424 // collect the group-kind and versions from the actual structured form 425 var actualCRD crdIsh 426 if err := kyaml.Unmarshal(rawContent, &actualCRD); err != nil { 427 continue 428 } 429 groupKind := schema.GroupKind{Group: actualCRD.Spec.Group, Kind: actualCRD.Spec.Names.Kind} 430 var versions map[string]struct{} 431 if len(actualCRD.Spec.Versions) == 0 { 432 versions = map[string]struct{}{actualCRD.Spec.Version: struct{}{}} 433 } else { 434 versions = make(map[string]struct{}, len(actualCRD.Spec.Versions)) 435 for _, ver := range actualCRD.Spec.Versions { 436 versions[ver.Name] = struct{}{} 437 } 438 } 439 440 // then actually unmarshal in a manner that preserves ordering, etc 441 var yamlNodeTree yaml.Node 442 if err := yaml.Unmarshal(rawContent, &yamlNodeTree); err != nil { 443 continue 444 } 445 446 // then store this CRDVersion of the CRD in a set, populating the set if necessary 447 if res[groupKind] == nil { 448 res[groupKind] = &partialCRDSet{ 449 GroupKind: groupKind, 450 NewSchemata: make(map[string]apiext.JSONSchemaProps), 451 Versions: make(map[string]struct{}), 452 } 453 } 454 for ver := range versions { 455 res[groupKind].Versions[ver] = struct{}{} 456 } 457 res[groupKind].CRDVersions = append(res[groupKind].CRDVersions, &partialCRD{ 458 Yaml: &yamlNodeTree, 459 FileName: fileInfo.Name(), 460 CRDVersion: typeMeta.APIVersion, 461 }) 462 } 463 return res, nil 464 } 465 466 // isSupportedAPIExtGroupVer checks if the given string-form group-version 467 // is one of the known apiextensions versions (v1, v1beta1). 468 func isSupportedAPIExtGroupVer(groupVer string) bool { 469 return groupVer == currentAPIExtVersion || groupVer == legacyAPIExtVersion 470 } 471 472 // crdIsh is a merged blob of CRD fields that looks enough like all versions of 473 // CRD to extract the relevant information for partialCRDSet and partialCRD. 474 // 475 // We keep this separate so it's clear what info we need, and so we don't break 476 // when we switch canonical internal versions and lose old fields while gaining 477 // new ones (like in v1beta1 --> v1). 478 // 479 // Its use is tied directly to crdsFromDirectory, and is mostly an implementation detail of that. 480 type crdIsh struct { 481 Spec struct { 482 Group string `json:"group"` 483 Names struct { 484 Kind string `json:"kind"` 485 } `json:"names"` 486 Versions []struct { 487 Name string `json:"name"` 488 } `json:"versions"` 489 Version string `json:"version"` 490 } `json:"spec"` 491 } 492 493 // legacySchema jumps through some hoops to convert a v1 schema to a v1beta1 schema. 494 func legacySchema(origSchema apiext.JSONSchemaProps) (apiextlegacy.JSONSchemaProps, error) { 495 shellCRD := apiext.CustomResourceDefinition{} 496 shellCRD.APIVersion = currentAPIExtVersion 497 shellCRD.Kind = "CustomResourceDefinition" 498 shellCRD.Spec.Versions = []apiext.CustomResourceDefinitionVersion{ 499 {Schema: &apiext.CustomResourceValidation{OpenAPIV3Schema: origSchema.DeepCopy()}}, 500 } 501 502 legacyCRD, err := crdgen.AsVersion(shellCRD, apiextlegacy.SchemeGroupVersion) 503 if err != nil { 504 return apiextlegacy.JSONSchemaProps{}, err 505 } 506 507 return *legacyCRD.(*apiextlegacy.CustomResourceDefinition).Spec.Validation.OpenAPIV3Schema, nil 508 }