gen.go 17 KB

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