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  }