github.com/alex123012/deckhouse-controller-tools@v0.0.0-20230510090815-d594daf1af8c/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  	"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 "sigs.k8s.io/controller-tools/pkg/crd"
    32  	crdmarkers "sigs.k8s.io/controller-tools/pkg/crd/markers"
    33  	"sigs.k8s.io/controller-tools/pkg/genall"
    34  	"sigs.k8s.io/controller-tools/pkg/loader"
    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  	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 := ioutil.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  }