gen.go 14 KB

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