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