Repository: kubernetes-sigs/controller-tools
Pull request #183 (2019-04-11 07:59:28)

Add crdschema generator
While the crd generator creates complete CRD manifests from tags, the crdschema generator is much less opinionated and only updates validation schemas in a given directory of CRD manifests.

```shell
# Generate OpenAPI v3 schemas for API packages and merge them into existing CRD manifests
$ controller-gen crdschema paths=./pkg/apis/... crdschema:manifests=./manifests output:dir=./manifests
```

k8s-ci-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/controller-tools/pull/183#" title="Author self-approved">sttts</a>*
To fully approve this pull request, please assign additional approvers.
We suggest the following additional approver: **pwittrock**

If they are not already assigned, you can assign the PR to them by writing `/assign @pwittrock` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fcontroller-tools).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubernetes-sigs/controller-tools/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["pwittrock"]} -->
sttts: /assign @DirectXMan12 @damemi @droot 
k8s-ci-robot: @sttts: GitHub didn't allow me to assign the following users: damemi.

Note that only [kubernetes-sigs members](https://github.com/orgs/kubernetes-sigs/people) and repo collaborators can be assigned and that issues/PRs can only have 10 assignees at the same time.
For more information please see [the contributor guide](https://git.k8s.io/community/contributors/guide/#issue-assignment-in-github)

<details>

In response to [this](https://github.com/kubernetes-sigs/controller-tools/pull/183#issuecomment-482010326):

>/assign @DirectXMan12 @damemi @droot 


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>
k8s-ci-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/controller-tools/pull/183#" title="Author self-approved">sttts</a>*
To fully approve this pull request, please assign additional approvers.
We suggest the following additional approver: **directxman12**

If they are not already assigned, you can assign the PR to them by writing `/assign @directxman12` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fcontroller-tools).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubernetes-sigs/controller-tools/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["directxman12"]} -->
DirectXMan12: We've actually been working on a similar refactoring that might help you, because other users want to be able to generate stand-alone validation schemas.  Hopefully that should make this easier.

As it stands, this code is a bit too hacky to merge, IMO, but the refactor should make it much nicer.

/hold
DirectXMan12: discussed a bit more with @sttts 

TL;DR: I like the idea of being able to generate a schema by itself (we actually have other people that want it too).  Generating a stand-alone schema, or a schema as a patch, should be fine.  Any patching should probably just be done using kustomize instead.  @mengqiy is working on a refactor that should make this less hacky.

cc @damemi as well
sttts: @DirectXMan12 @damemi ported this to the new generator pattern.
k8s-ci-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/controller-tools/pull/183#" title="Author self-approved">sttts</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **directxman12**
You can assign the PR to them by writing `/assign @directxman12` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fcontroller-tools).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubernetes-sigs/controller-tools/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["directxman12"]} -->
sttts: @DirectXMan12 updated. I integrated crd-schema into controller-gen.
sttts: green now.
sttts: @DirectXMan12 gently ping
mengqiy: Need to drop `/verdor` code.

sttts: @mengqiy done
sttts: Rebased after conflict.
sttts: @DirectXMan12 @mengqiy what is missing to get this merged?
DirectXMan12: doing an initial pass.  "fixtures" need to be in a "testdata" directory so the go tooling ignores them: https://golang.org/cmd/go/#hdr-Test_packages
sttts: We cannot move fixtures to testdata because then your parser does not work as it cannot find dependencies:
```
testdata/pkg/apis/kubebuilder/v1/types_example.go:20:2: cannot find package "k8s.io/apimachinery/pkg/apis/meta/v1" in any of:
	/Users/sts/.bin/go-1.12/src/k8s.io/apimachinery/pkg/apis/meta/v1 (from $GOROOT)
	/Users/sts/Quellen/kubernetes/src/k8s.io/apimachinery/pkg/apis/meta/v1 (from $GOPATH)
```

LorbusChris: @DirectXMan12 @damemi @droot could you have another look?
This is a really very useful feature for us.
s-urbaniak: :+1: on this, this would help us a lot in many operators doing schema generation.
xmudrii: +1
This would be a very useful feature :100:
DirectXMan12: yeah, I'm super-sorry about this one.  I've just been really swamped recently, and it kinda fell off my radar.
DirectXMan12: ok, I've pushed some changes to this that bring it to a position where I'm more comfortable merging this (I'm still a bit uncomfortable with all the raw YAML manipulation, but it's hard to avoid that with your usecase constraints).

Namely:
- fixed the tests to use the proper testdata directory
- updated to use yaml.v3 node trees, which preserve style, comments, etc and make the path traversal a bit cleaner
- **removed the diffing logic, on the presumption that using `diff` is ok**.  If this isn't true, please do let me know, but it seemed like there were too many things rolled into one here.  We could maybe add a generic diffing output rule
- made CRD writes unconditional.  yaml.v3 should make unchanged writes a noop (modulo some whitespace changes).  Please lmk if this is a problem.

The "no reordering of maps" properties are still preserved, as are comments and flow vs block style.

I'd like to have @droot take a quick look at this too, since I've refactored a bunch of stuff myself.
If @sttts or anyone else familiar with the usecase constraints on this thread can also take a look, I would appreciate it.  I'm trying to come to an arrangement here so that we can ensure this code fits in a bit better with the overall vision on controller-tools -- as submitted, it didn't decompose well, and pulled in a number of different tasks into a single, bespoke tool.
sttts: @DirectXMan12 your rewrite looks good.

I am having problems to run the tests though, and also to run the binary (probably the same issue):
```
$ controller-gen schemapatch paths=./vendor/github.com/openshift/api/... schemapatch:manifests=./manifests output:dir=./manifests
Error: unable to parse option "schemapatch": [missing argument "manifests"]
```
DirectXMan12: whoops, didn't actually get merged here.  Will fix this up this week and get this merged.

Patch:
diff --git a/Gopkg.lock b/Gopkg.lock
index 426702a2..8b5add98 100644
--- a/Gopkg.lock
+++ b/Gopkg.lock
@@ -267,6 +267,14 @@
   revision = "51d6538a90f86fe93ac480b35f37b2be17fef232"
   version = "v2.2.2"
 
+[[projects]]
+  branch = "v3"
+  digest = "0:"
+  name = "gopkg.in/yaml.v3"
+  packages = ["."]
+  pruneopts = "UT"
+  revision = "674ba3eaed223079f9377fbb0c98c0951bf6ec10"
+
 [[projects]]
   digest = "1:45cc31023bf536943730c88567bd196e81d5aba7fa965649eb34e46c0debd0e1"
   name = "k8s.io/api"
@@ -350,6 +358,7 @@
     "github.com/spf13/pflag",
     "golang.org/x/tools/go/packages",
     "golang.org/x/tools/go/packages/packagestest",
+    "gopkg.in/yaml.v3",
     "k8s.io/api/admissionregistration/v1beta1",
     "k8s.io/api/rbac/v1",
     "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1",
diff --git a/cmd/controller-gen/main.go b/cmd/controller-gen/main.go
index 5bdaa420..619b2473 100644
--- a/cmd/controller-gen/main.go
+++ b/cmd/controller-gen/main.go
@@ -31,6 +31,7 @@ import (
 	prettyhelp "sigs.k8s.io/controller-tools/pkg/genall/help/pretty"
 	"sigs.k8s.io/controller-tools/pkg/markers"
 	"sigs.k8s.io/controller-tools/pkg/rbac"
+	"sigs.k8s.io/controller-tools/pkg/schemapatcher"
 	"sigs.k8s.io/controller-tools/pkg/webhook"
 )
 
@@ -46,10 +47,11 @@ var (
 	// each turns into a command line option,
 	// and has options for output forms.
 	allGenerators = map[string]genall.Generator{
-		"crd":     crd.Generator{},
-		"rbac":    rbac.Generator{},
-		"object":  deepcopy.Generator{},
-		"webhook": webhook.Generator{},
+		"crd":         crd.Generator{},
+		"rbac":        rbac.Generator{},
+		"object":      deepcopy.Generator{},
+		"webhook":     webhook.Generator{},
+		"schemapatch": schemapatcher.Generator{},
 	}
 
 	// allOutputRules defines the list of all known output rules, giving
@@ -134,6 +136,9 @@ func main() {
 	# Generate deepcopy/runtime.Object implementations for a particular file
 	controller-gen object paths=./apis/v1beta1/some_types.go
 
+	# Generate OpenAPI v3 schemas for API packages and merge them into existing CRD manifests
+	controller-gen schemapatch:manifests=./manifests output:dir=./manifests paths=./pkg/apis/... 
+
 	# Run all the generators for a given project
 	controller-gen paths=./apis/...
 
diff --git a/go.mod b/go.mod
index 4eb45403..835d84d3 100644
--- a/go.mod
+++ b/go.mod
@@ -25,6 +25,7 @@ require (
 	gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/yaml.v2 v2.2.2 // indirect
+	gopkg.in/yaml.v3 v3.0.0-20190709130402-674ba3eaed22
 	k8s.io/api v0.0.0-20190409021203-6e4e0e4f393b
 	k8s.io/apiextensions-apiserver v0.0.0-20190409022649-727a075fdec8
 	k8s.io/apimachinery v0.0.0-20190404173353-6a84e37a896d
diff --git a/go.sum b/go.sum
index 4d20c346..42591949 100644
--- a/go.sum
+++ b/go.sum
@@ -84,6 +84,8 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20190709130402-674ba3eaed22 h1:0efs3hwEZhFKsCoP8l6dDB1AZWMgnEl3yWXWRZTOaEA=
+gopkg.in/yaml.v3 v3.0.0-20190709130402-674ba3eaed22/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 k8s.io/api v0.0.0-20190409021203-6e4e0e4f393b h1:aBGgKJUM9Hk/3AE8WaZIApnTxG35kbuQba2w+SXqezo=
 k8s.io/api v0.0.0-20190409021203-6e4e0e4f393b/go.mod h1:iuAfoD4hCxJ8Onx9kaTIt30j7jUFS00AXQi6QMi99vA=
 k8s.io/apiextensions-apiserver v0.0.0-20190409022649-727a075fdec8 h1:q1Qvjzs/iEdXF6A1a8H3AKVFDzJNcJn3nXMs6R6qFtA=
diff --git a/pkg/crd/gen.go b/pkg/crd/gen.go
index de4a00f1..8a4aad67 100644
--- a/pkg/crd/gen.go
+++ b/pkg/crd/gen.go
@@ -62,14 +62,14 @@ func (g Generator) Generate(ctx *genall.GenerationContext) error {
 		parser.NeedPackage(root)
 	}
 
-	metav1Pkg := findMetav1(ctx.Roots)
+	metav1Pkg := FindMetav1(ctx.Roots)
 	if metav1Pkg == nil {
 		// no objects in the roots, since nothing imported metav1
 		return nil
 	}
 
 	// TODO: allow selecting a specific object
-	kubeKinds := findKubeKinds(parser, metav1Pkg)
+	kubeKinds := FindKubeKinds(parser, metav1Pkg)
 	if len(kubeKinds) == 0 {
 		// no objects in the roots
 		return nil
@@ -116,9 +116,9 @@ func toTrivialVersions(crd *apiext.CustomResourceDefinition) {
 	crd.Spec.AdditionalPrinterColumns = canonicalColumns
 }
 
-// findMetav1 locates the actual package representing metav1 amongst
+// FindMetav1 locates the actual package representing metav1 amongst
 // the imports of the roots.
-func findMetav1(roots []*loader.Package) *loader.Package {
+func FindMetav1(roots []*loader.Package) *loader.Package {
 	for _, root := range roots {
 		pkg := root.Imports()["k8s.io/apimachinery/pkg/apis/meta/v1"]
 		if pkg != nil {
@@ -128,10 +128,10 @@ func findMetav1(roots []*loader.Package) *loader.Package {
 	return nil
 }
 
-// findKubeKinds locates all types that contain TypeMeta and ObjectMeta
+// FindKubeKinds locates all types that contain TypeMeta and ObjectMeta
 // (and thus may be a Kubernetes object), and returns the corresponding
 // group-kinds.
-func findKubeKinds(parser *Parser, metav1Pkg *loader.Package) []schema.GroupKind {
+func FindKubeKinds(parser *Parser, metav1Pkg *loader.Package) []schema.GroupKind {
 	// TODO(directxman12): technically, we should be finding metav1 per-package
 	var kubeKinds []schema.GroupKind
 	for typeIdent, info := range parser.Types {
diff --git a/pkg/crd/parser.go b/pkg/crd/parser.go
index c2fe0712..6a22cac1 100644
--- a/pkg/crd/parser.go
+++ b/pkg/crd/parser.go
@@ -58,6 +58,10 @@ type Parser struct {
 	GroupVersions map[*loader.Package]schema.GroupVersion
 	// CustomResourceDefinitions contains the known CustomResourceDefinitions for types in this parser.
 	CustomResourceDefinitions map[schema.GroupKind]apiext.CustomResourceDefinition
+	// FlattenedSchemata contains fully flattened schemata for use in building
+	// CustomResourceDefinition validation.  Each schema has been flattened by the flattener,
+	// and then embedded fields have been flattened with FlattenEmbedded.
+	FlattenedSchemata map[TypeIdent]apiext.JSONSchemaProps
 
 	// PackageOverrides indicates that the loading of any package with
 	// the given path should be handled by the given overrider.
@@ -95,6 +99,9 @@ func (p *Parser) init() {
 	if p.CustomResourceDefinitions == nil {
 		p.CustomResourceDefinitions = make(map[schema.GroupKind]apiext.CustomResourceDefinition)
 	}
+	if p.FlattenedSchemata == nil {
+		p.FlattenedSchemata = make(map[TypeIdent]apiext.JSONSchemaProps)
+	}
 }
 
 // indexTypes loads all types in the package into Types.
@@ -162,8 +169,20 @@ func (p *Parser) NeedSchemaFor(typ TypeIdent) {
 	schema := infoToSchema(ctxForInfo)
 
 	p.Schemata[typ] = *schema
+}
+
+func (p *Parser) NeedFlattenedSchemaFor(typ TypeIdent) {
+	p.init()
+
+	if _, knownSchema := p.FlattenedSchemata[typ]; knownSchema {
+		return
+	}
+
+	p.NeedSchemaFor(typ)
+	partialFlattened := p.flattener.FlattenType(typ)
+	fullyFlattened := FlattenEmbedded(partialFlattened, typ.Package)
 
-	return
+	p.FlattenedSchemata[typ] = *fullyFlattened
 }
 
 // NeedCRDFor lives off in spec.go
diff --git a/pkg/crd/spec.go b/pkg/crd/spec.go
index f7a43896..f2fbd80e 100644
--- a/pkg/crd/spec.go
+++ b/pkg/crd/spec.go
@@ -160,15 +160,17 @@ func (p *Parser) NeedCRDFor(groupKind schema.GroupKind, maxDescLen *int) {
 		if typeInfo == nil {
 			continue
 		}
-		fullSchema := FlattenEmbedded(p.flattener.FlattenType(typeIdent), pkg)
+		p.NeedFlattenedSchemaFor(typeIdent)
+		fullSchema := p.FlattenedSchemata[typeIdent]
+		fullSchema = *fullSchema.DeepCopy() // don't mutate the cache (we might be truncating description, etc)
 		if maxDescLen != nil {
-			TruncateDescription(fullSchema, *maxDescLen)
+			TruncateDescription(&fullSchema, *maxDescLen)
 		}
 		ver := apiext.CustomResourceDefinitionVersion{
 			Name:   p.GroupVersions[pkg].Version,
 			Served: true,
 			Schema: &apiext.CustomResourceValidation{
-				OpenAPIV3Schema: fullSchema,
+				OpenAPIV3Schema: &fullSchema, // fine to take a reference since we deepcopy above
 			},
 		}
 		crd.Spec.Versions = append(crd.Spec.Versions, ver)
diff --git a/pkg/schemapatcher/gen.go b/pkg/schemapatcher/gen.go
new file mode 100644
index 00000000..21901da7
--- /dev/null
+++ b/pkg/schemapatcher/gen.go
@@ -0,0 +1,368 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package schemapatcher
+
+import (
+	"fmt"
+	"io/ioutil"
+	"path/filepath"
+
+	"gopkg.in/yaml.v3"
+	apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
+	"k8s.io/apimachinery/pkg/api/equality"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	kyaml "sigs.k8s.io/yaml"
+
+	crdgen "sigs.k8s.io/controller-tools/pkg/crd"
+	crdmarkers "sigs.k8s.io/controller-tools/pkg/crd/markers"
+	"sigs.k8s.io/controller-tools/pkg/genall"
+	"sigs.k8s.io/controller-tools/pkg/markers"
+	yamlop "sigs.k8s.io/controller-tools/pkg/schemapatcher/internal/yaml"
+)
+
+// NB(directxman12): this code is quite fragile, but there are a sufficient
+// number of corner cases that it's hard to decompose into separate tools.
+// When in doubt, ping @sttts.
+//
+// Namely:
+// - It needs to only update existing versions
+// - It needs to make "stable" changes that don't mess with map key ordering
+//   (in order to facilitate validating that no change has occurred)
+// - It needs to collapse identical schema versions into a top-level schema,
+//   if all versions are identical (this is a common requirement to all CRDs,
+//   but in this case it means simple jsonpatch wouldn't suffice)
+
+// TODO(directxman12): When CRD v1 rolls around, consider splitting this into a
+// tool that generates a patch, and a separate tool for applying stable YAML
+// patches.
+
+// Generator patches existing CRDs with new schemata.
+//
+// For single-version CRDs, it will simply replace the global schema.
+//
+// For multi-version CRDs, it will replace schemata of existing versions
+// and *clear the schema* from any versions not specified in the Go code.
+// It will *not* add new versions, or remove old ones.
+//
+// For multi-version CRDs with identical schemata, it will take care of
+// lifting the per-version schema up to the global schema.
+type Generator struct {
+	// ManifestsPath contains the CustomResourceDefinition YAML files.
+	ManifestsPath string `marker:"manifests"`
+
+	// MaxDescLen specifies the maximum description length for fields in CRD's OpenAPI schema.
+	//
+	// 0 indicates drop the description for all fields completely.
+	// n indicates limit the description to at most n characters and truncate the description to
+	// closest sentence boundary if it exceeds n characters.
+	MaxDescLen *int `marker:",optional"`
+}
+
+var _ genall.Generator = &Generator{}
+
+func (Generator) RegisterMarkers(into *markers.Registry) error {
+	return crdmarkers.Register(into)
+}
+
+func (g Generator) Generate(ctx *genall.GenerationContext) (result error) {
+	parser := &crdgen.Parser{
+		Collector: ctx.Collector,
+		Checker:   ctx.Checker,
+	}
+
+	crdgen.AddKnownTypes(parser)
+	for _, root := range ctx.Roots {
+		parser.NeedPackage(root)
+	}
+
+	metav1Pkg := crdgen.FindMetav1(ctx.Roots)
+	if metav1Pkg == nil {
+		// no objects in the roots, since nothing imported metav1
+		return nil
+	}
+
+	// load existing CRD manifests with group-kind and versions
+	partialCRDs, err := crdsFromDirectory(ctx, g.ManifestsPath)
+	if err != nil {
+		return err
+	}
+
+	// generate schemata for the types we care about, and save them to be written later.
+	for _, groupKind := range crdgen.FindKubeKinds(parser, metav1Pkg) {
+		existingInfo, wanted := partialCRDs[groupKind]
+		if !wanted {
+			continue
+		}
+
+		for pkg, gv := range parser.GroupVersions {
+			if gv.Group != groupKind.Group {
+				continue
+			}
+			if _, wantedVersion := existingInfo.Versions[gv.Version]; !wantedVersion {
+				continue
+			}
+
+			typeIdent := crdgen.TypeIdent{Package: pkg, Name: groupKind.Kind}
+			parser.NeedFlattenedSchemaFor(typeIdent)
+
+			fullSchema := parser.FlattenedSchemata[typeIdent]
+			if g.MaxDescLen != nil {
+				fullSchema = *fullSchema.DeepCopy()
+				crdgen.TruncateDescription(&fullSchema, *g.MaxDescLen)
+			}
+			existingInfo.NewSchemata[gv.Version] = fullSchema
+		}
+	}
+
+	// patch existing CRDs with new schemata
+	for _, existingInfo := range partialCRDs {
+		// first, figure out if we need to merge schemata together if they're *all*
+		// identical (meaning we also don't have any "unset" versions)
+
+		if len(existingInfo.NewSchemata) == 0 {
+			continue
+		}
+
+		// copy over the new versions that we have, keeping old versions so
+		// that we can tell if a schema would be nill
+		var someVer string
+		for ver := range existingInfo.NewSchemata {
+			someVer = ver
+			existingInfo.Versions[ver] = struct{}{}
+		}
+
+		allSame := true
+		firstSchema := existingInfo.NewSchemata[someVer]
+		for ver := range existingInfo.Versions {
+			otherSchema, hasSchema := existingInfo.NewSchemata[ver]
+			if !hasSchema || !equality.Semantic.DeepEqual(firstSchema, otherSchema) {
+				allSame = false
+				break
+			}
+		}
+
+		if allSame {
+			if err := existingInfo.setGlobalSchema(); err != nil {
+				return fmt.Errorf("failed to set global firstSchema for %s: %v", existingInfo.GroupKind, err)
+			}
+		} else {
+			if err := existingInfo.setVersionedSchemata(); err != nil {
+				return fmt.Errorf("failed to set versioned schemas for %s: %v", existingInfo.GroupKind, err)
+			}
+		}
+	}
+
+	// write the final result out to the new location
+	for _, crd := range partialCRDs {
+		if err := func() error {
+			outWriter, err := ctx.OutputRule.Open(nil, crd.FileName)
+			if err != nil {
+				return err
+			}
+			defer outWriter.Close()
+
+			enc := yaml.NewEncoder(outWriter)
+			// yaml.v2 defaults to indent=2, yaml.v3 defaults to indent=4,
+			// so be compatible with everything else in k8s and choose 2.
+			enc.SetIndent(2)
+			return enc.Encode(crd.Yaml)
+		}(); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// partialCRD tracks modifications to the schemata of a CRD.  It contains the
+// raw YAML representation of a CRD, plus some structured content (versions,
+// filename, etc) for easy lookup, and any new schemata registered.
+type partialCRD struct {
+	GroupKind schema.GroupKind
+	Yaml      *yaml.Node
+	Versions  map[string]struct{}
+	FileName  string
+
+	NewSchemata map[string]apiext.JSONSchemaProps
+}
+
+// setGlobalSchema sets the global schema to one of the schemata
+// for this CRD.  All schemata must be identical for this to be a valid operation.
+func (e *partialCRD) setGlobalSchema() error {
+	// there's no easy way to get a "random" key from a go map :-/
+	var schema apiext.JSONSchemaProps
+	for ver := range e.NewSchemata {
+		schema = e.NewSchemata[ver]
+		break
+	}
+
+	schemaNodeTree, err := yamlop.ToYAML(schema)
+	if err != nil {
+		return err
+	}
+	schemaNodeTree = schemaNodeTree.Content[0] // get rid of the document node
+	yamlop.SetStyle(schemaNodeTree, 0)         // clear the style so it defaults to auto-style-choice
+
+	if err := yamlop.SetNode(e.Yaml, *schemaNodeTree, "spec", "validation", "openAPIV3Schema"); err != nil {
+		return err
+	}
+
+	versions, found, err := e.getVersionsNode()
+	if err != nil {
+		return err
+	}
+	if !found {
+		return nil
+	}
+	for i, verNode := range versions.Content {
+		if err := yamlop.DeleteNode(verNode, "schema"); err != nil {
+			return fmt.Errorf("spec.versions[%d]: %v", i, err)
+		}
+	}
+
+	return nil
+}
+
+// getVersionsNode gets the YAML node of .spec.versions YAML mapping,
+// if returning the node, and whether or not it was present.
+func (e *partialCRD) getVersionsNode() (*yaml.Node, bool, error) {
+	versions, found, err := yamlop.GetNode(e.Yaml, "spec", "versions")
+	if err != nil {
+		return nil, false, err
+	}
+	if !found {
+		return nil, false, nil
+	}
+	if versions.Kind != yaml.SequenceNode {
+		return nil, true, fmt.Errorf("unexpected non-sequence versions")
+	}
+	return versions, found, nil
+}
+
+// setVersionedSchemata populates all existing versions with new schemata,
+// wiping the schema of any version that doesn't have a listed schema.
+// Any "unknown" versions are ignored.
+func (e *partialCRD) setVersionedSchemata() error {
+	var err error
+	if err := yamlop.DeleteNode(e.Yaml, "spec", "validation"); err != nil {
+		return err
+	}
+
+	versions, found, err := e.getVersionsNode()
+	if err != nil {
+		return err
+	}
+	if !found {
+		return fmt.Errorf("unexpected missing versions")
+	}
+
+	for i, verNode := range versions.Content {
+		nameNode, _, _ := yamlop.GetNode(verNode, "name")
+		if nameNode.Kind != yaml.ScalarNode || nameNode.ShortTag() != "!!str" {
+			return fmt.Errorf("version name was not a string at spec.versions[%d]", i)
+		}
+		name := nameNode.Value
+		if name == "" {
+			return fmt.Errorf("unexpected empty name at spec.versions[%d]", i)
+		}
+		newSchema, found := e.NewSchemata[name]
+		if !found {
+			if err := yamlop.DeleteNode(verNode, "schema"); err != nil {
+				return fmt.Errorf("spec.versions[%d]: %v", i, err)
+			}
+		} else {
+			schemaNodeTree, err := yamlop.ToYAML(newSchema)
+			if err != nil {
+				return fmt.Errorf("failed to convert schema to YAML: %v", err)
+			}
+			schemaNodeTree = schemaNodeTree.Content[0] // get rid of the document node
+			yamlop.SetStyle(schemaNodeTree, 0)         // clear the style so it defaults to an auto-chosen one
+			if err := yamlop.SetNode(verNode, *schemaNodeTree, "schema", "openAPIV3Schema"); err != nil {
+				return fmt.Errorf("spec.versions[%d]: %v", i, err)
+			}
+		}
+	}
+	return nil
+}
+
+// crdsFromDirectory returns loads all CRDs from the given directory in a
+// manner that preserves ordering, comments, etc in order to make patching
+// minimally invasive.  Returned CRDs are mapped by group-kind.
+func crdsFromDirectory(ctx *genall.GenerationContext, dir string) (map[schema.GroupKind]*partialCRD, error) {
+	apiextAPIVersion := apiext.SchemeGroupVersion.String()
+
+	res := map[schema.GroupKind]*partialCRD{}
+	dirEntries, err := ioutil.ReadDir(dir)
+	if err != nil {
+		return nil, err
+	}
+	for _, fileInfo := range dirEntries {
+		// find all files that are YAML
+		if fileInfo.IsDir() || filepath.Ext(fileInfo.Name()) != ".yaml" {
+			continue
+		}
+
+		rawContent, err := ctx.ReadFile(filepath.Join(dir, fileInfo.Name()))
+		if err != nil {
+			return nil, err
+		}
+
+		// NB(directxman12): we could use the universal deserializer for this, but it's
+		// really pretty clunky, and the alternative is actually kinda easier to understand
+
+		// ensure that this is a CRD
+		var typeMeta metav1.TypeMeta
+		if err := kyaml.Unmarshal(rawContent, &typeMeta); err != nil {
+			continue
+		}
+		if typeMeta.APIVersion != apiextAPIVersion || typeMeta.Kind != "CustomResourceDefinition" {
+			continue
+		}
+
+		// collect the group-kind and versions from the actual structured form
+		var actualCRD apiext.CustomResourceDefinition
+		if err := kyaml.Unmarshal(rawContent, &actualCRD); err != nil {
+			continue
+		}
+		groupKind := schema.GroupKind{Group: actualCRD.Spec.Group, Kind: actualCRD.Spec.Names.Kind}
+		var versions map[string]struct{}
+		if len(actualCRD.Spec.Versions) == 0 {
+			versions = map[string]struct{}{actualCRD.Spec.Version: struct{}{}}
+		} else {
+			versions = make(map[string]struct{}, len(actualCRD.Spec.Versions))
+			for _, ver := range actualCRD.Spec.Versions {
+				versions[ver.Name] = struct{}{}
+			}
+		}
+
+		// then actually unmarshal in a manner that preserves ordering, etc
+		var yamlNodeTree yaml.Node
+		if err := yaml.Unmarshal(rawContent, &yamlNodeTree); err != nil {
+			continue
+		}
+
+		res[groupKind] = &partialCRD{
+			GroupKind:   groupKind,
+			Yaml:        &yamlNodeTree,
+			Versions:    versions,
+			FileName:    fileInfo.Name(),
+			NewSchemata: make(map[string]apiext.JSONSchemaProps),
+		}
+	}
+	return res, nil
+}
diff --git a/pkg/schemapatcher/internal/yaml/convert.go b/pkg/schemapatcher/internal/yaml/convert.go
new file mode 100644
index 00000000..ed47c0a4
--- /dev/null
+++ b/pkg/schemapatcher/internal/yaml/convert.go
@@ -0,0 +1,61 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package yaml
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"gopkg.in/yaml.v3"
+)
+
+// ToYAML converts some object that serializes to JSON into a YAML node tree.
+// It's useful since it pays attention to JSON tags, unlike yaml.Unmarshal or
+// yaml.Node.Decode.
+func ToYAML(rawObj interface{}) (*yaml.Node, error) {
+	if rawObj == nil {
+		return &yaml.Node{Kind: yaml.ScalarNode, Value: "null", Tag: "!!null"}, nil
+	}
+
+	rawJSON, err := json.Marshal(rawObj)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal object: %v", err)
+	}
+
+	var out yaml.Node
+	if err := yaml.Unmarshal(rawJSON, &out); err != nil {
+		return nil, fmt.Errorf("unable to unmarshal marshalled object: %v", err)
+	}
+	return &out, nil
+}
+
+// changeAll calls the given callback for all nodes in
+// the given YAML node tree.
+func changeAll(root *yaml.Node, cb func(*yaml.Node)) {
+	cb(root)
+	for _, child := range root.Content {
+		changeAll(child, cb)
+	}
+}
+
+// SetStyle sets the style for all nodes in the given
+// node tree to the given style.
+func SetStyle(root *yaml.Node, style yaml.Style) {
+	changeAll(root, func(node *yaml.Node) {
+		node.Style = style
+	})
+}
diff --git a/pkg/schemapatcher/internal/yaml/nested.go b/pkg/schemapatcher/internal/yaml/nested.go
new file mode 100644
index 00000000..e6bcbb71
--- /dev/null
+++ b/pkg/schemapatcher/internal/yaml/nested.go
@@ -0,0 +1,87 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package yaml
+
+import (
+	"fmt"
+
+	"gopkg.in/yaml.v3"
+)
+
+// ValueInMapping finds the value node with the corresponding string key
+// in the given mapping node.  If the given node is not a mapping, an
+// error will be returned.
+func ValueInMapping(root *yaml.Node, key string) (*yaml.Node, error) {
+	if root.Kind != yaml.MappingNode {
+		return nil, fmt.Errorf("unexpected non-mapping node")
+	}
+
+	for i := 0; i < len(root.Content)/2; i++ {
+		keyNode := root.Content[i*2]
+		if keyNode.Value == key {
+			return root.Content[i*2+1], nil
+		}
+	}
+	return nil, nil
+}
+
+// asCloseAsPossible goes as deep on the given path as possible, returning the
+// last node that existed from the given path in the given tree of mapping
+// nodes, as well as the rest of the path that could not be fetched, if any.
+func asCloseAsPossible(root *yaml.Node, path ...string) (*yaml.Node, []string, error) {
+	if root == nil {
+		return nil, path, nil
+	}
+	if root.Kind == yaml.DocumentNode && len(root.Content) > 0 {
+		root = root.Content[0]
+	}
+
+	currNode := root
+	for ; len(path) > 0; path = path[1:] {
+		if currNode.Kind != yaml.MappingNode {
+			return nil, nil, fmt.Errorf("unexpected non-mapping (%v) before path %v", currNode.Kind, path)
+		}
+
+		nextNode, err := ValueInMapping(currNode, path[0])
+		if err != nil {
+			return nil, nil, fmt.Errorf("unable to get next node in path %v: %v", path, err)
+		}
+
+		if nextNode == nil {
+			// we're as close as possible
+			break
+		}
+
+		currNode = nextNode
+	}
+
+	return currNode, path, nil
+}
+
+// GetNode gets the node at the given path in the given sequence of mapping
+// nodes, or, if it doesn't exist, returning false.
+func GetNode(root *yaml.Node, path ...string) (*yaml.Node, bool, error) {
+	resNode, restPath, err := asCloseAsPossible(root, path...)
+	if err != nil {
+		return nil, false, err
+	}
+	// more path means the node didn't exist
+	if len(restPath) != 0 {
+		return nil, false, nil
+	}
+	return resNode, true, nil
+}
diff --git a/pkg/schemapatcher/internal/yaml/set.go b/pkg/schemapatcher/internal/yaml/set.go
new file mode 100644
index 00000000..ede417f1
--- /dev/null
+++ b/pkg/schemapatcher/internal/yaml/set.go
@@ -0,0 +1,80 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package yaml
+
+import (
+	"fmt"
+
+	"gopkg.in/yaml.v3"
+)
+
+// SetNode sets the given path to the given yaml Node, creating mapping nodes along the way.
+func SetNode(root *yaml.Node, val yaml.Node, path ...string) error {
+	currNode, path, err := asCloseAsPossible(root, path...)
+	if err != nil {
+		return err
+	}
+
+	if len(path) > 0 {
+		if currNode.Kind != yaml.MappingNode {
+			return fmt.Errorf("unexpected non-mapping before path %v", path)
+		}
+
+		for ; len(path) > 0; path = path[1:] {
+			keyNode := yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle, Value: path[0]}
+			nextNode := &yaml.Node{Kind: yaml.MappingNode}
+			currNode.Content = append(currNode.Content, &keyNode, nextNode)
+
+			currNode = nextNode
+		}
+	}
+
+	*currNode = val
+	return nil
+}
+
+// DeleteNode deletes the node at the given path in the given tree of mapping nodes.
+// It's a noop if the path doesn't exist.
+func DeleteNode(root *yaml.Node, path ...string) error {
+	if len(path) == 0 {
+		return fmt.Errorf("must specify a path to delete")
+	}
+	pathToParent, keyToDelete := path[:len(path)-1], path[len(path)-1]
+	parentNode, path, err := asCloseAsPossible(root, pathToParent...)
+	if err != nil {
+		return err
+	}
+	if len(path) > 0 {
+		// no-op, parent node doesn't exist
+		return nil
+	}
+
+	if parentNode.Kind != yaml.MappingNode {
+		return fmt.Errorf("unexpected non-mapping node")
+	}
+
+	for i := 0; i < len(parentNode.Content)/2; i++ {
+		keyNode := parentNode.Content[i*2]
+		if keyNode.Value == keyToDelete {
+			parentNode.Content = append(parentNode.Content[:i*2], parentNode.Content[i*2+2:]...)
+			return nil
+		}
+	}
+
+	// no-op, key not found in parent node
+	return nil
+}


Test patch:
diff --git a/pkg/schemapatcher/crdschema_suite_test.go b/pkg/schemapatcher/crdschema_suite_test.go
new file mode 100644
index 00000000..b5c2125a
--- /dev/null
+++ b/pkg/schemapatcher/crdschema_suite_test.go
@@ -0,0 +1,29 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package schemapatcher_test
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+func TestInPlaceCRDSchemaGeneration(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "In-Place CRD Patching Suite")
+}
diff --git a/pkg/schemapatcher/gen_integration_test.go b/pkg/schemapatcher/gen_integration_test.go
new file mode 100644
index 00000000..28d3577d
--- /dev/null
+++ b/pkg/schemapatcher/gen_integration_test.go
@@ -0,0 +1,74 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package schemapatcher_test
+
+import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+
+	"github.com/google/go-cmp/cmp"
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+
+	"sigs.k8s.io/controller-tools/pkg/genall"
+	. "sigs.k8s.io/controller-tools/pkg/schemapatcher"
+)
+
+var _ = Describe("CRD Patching From Parsing to Editing", func() {
+	It("should properly generate and patch the test CRDs", func() {
+		// TODO(directxman12): I've ported these over from @sttts's tests,
+		// but they should really probably not be writing to an actual filesystem
+
+		By("switching into testdata to appease go modules")
+		cwd, err := os.Getwd()
+		Expect(err).NotTo(HaveOccurred())
+		Expect(os.Chdir("./testdata")).To(Succeed()) // go modules are directory-sensitive
+		defer func() { Expect(os.Chdir(cwd)).To(Succeed()) }()
+
+		By("loading the generation runtime")
+		crdSchemaGen := &Generator{
+			ManifestsPath: "./manifests",
+		}
+		rt, err := genall.Generators{crdSchemaGen}.ForRoots("./...")
+		Expect(err).NotTo(HaveOccurred())
+
+		outputDir, err := ioutil.TempDir("", "controller-tools-test")
+		Expect(err).NotTo(HaveOccurred())
+		defer os.RemoveAll(outputDir)
+		rt.OutputRules.Default = genall.OutputToDirectory(outputDir)
+
+		By("running the generator")
+		Expect(rt.Run()).To(BeFalse(), "unexpectedly had errors")
+
+		By("loading the output files")
+		expectedFiles, err := ioutil.ReadDir("expected")
+		Expect(err).NotTo(HaveOccurred())
+
+		for _, expectedFile := range expectedFiles {
+			By("reading the expected and actual files for " + expectedFile.Name())
+			actualContents, err := ioutil.ReadFile(filepath.Join(outputDir, expectedFile.Name()))
+			Expect(err).NotTo(HaveOccurred())
+
+			expectedContents, err := ioutil.ReadFile(filepath.Join("expected", expectedFile.Name()))
+			Expect(err).NotTo(HaveOccurred())
+
+			By("checking that the expected and actual files for " + expectedFile.Name() + " are identical")
+			Expect(actualContents).To(Equal(expectedContents), "contents not as expected, check pkg/schemapatcher/testdata/README.md for more details.\n\nDiff:\n\n%s", cmp.Diff(string(actualContents), string(expectedContents)))
+		}
+	})
+})
diff --git a/pkg/schemapatcher/internal/yaml/delete_test.go b/pkg/schemapatcher/internal/yaml/delete_test.go
new file mode 100644
index 00000000..739eb8c0
--- /dev/null
+++ b/pkg/schemapatcher/internal/yaml/delete_test.go
@@ -0,0 +1,89 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    htcp://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package yaml
+
+import (
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+	"gopkg.in/yaml.v3"
+)
+
+var _ = Describe("DeleteNode", func() {
+	tests := []struct {
+		name    string
+		obj     interface{}
+		path    []string
+		want    interface{}
+		wantErr bool
+	}{
+		/*{name: "non-empty, unknown path",
+			obj: map[string]interface{}{
+				"bar": int64(42),
+			},
+			path:  []string{"foo"},
+			want: map[string]interface{}{
+				"bar": int64(42),
+			},
+		},
+		{name: "non-empty, existing path",
+			obj: map[string]interface{}{
+				"bar": int64(42),
+			},
+			path:  []string{"bar"},
+			want: map[string]interface{}{},
+		},*/
+		{name: "non-empty, long path",
+			obj: map[string]interface{}{
+				"foo": map[string]interface{}{
+					"bar": int64(42),
+				},
+			},
+			path: []string{"foo", "bar"},
+			want: map[string]interface{}{
+				"foo": map[string]interface{}{},
+			},
+		},
+	}
+
+	for i := range tests {
+		tc := tests[i]
+		It("should support "+tc.name, func() {
+			By("setting up the test objects and desired values")
+			obj, err := ToYAML(tc.obj)
+			Expect(err).NotTo(HaveOccurred())
+			want, err := ToYAML(tc.want)
+			Expect(err).NotTo(HaveOccurred())
+
+			By("calling DeleteNode")
+			if tc.wantErr {
+				Expect(DeleteNode(obj, tc.path...)).NotTo(Succeed())
+				return // don't check output value if there's an error
+			}
+
+			Expect(DeleteNode(obj, tc.path...)).To(Succeed())
+
+			By("marshalling the desired and actual objects")
+			wantOut, err := yaml.Marshal(want)
+			Expect(err).NotTo(HaveOccurred())
+			objOut, err := yaml.Marshal(obj)
+			Expect(err).NotTo(HaveOccurred())
+
+			By("comparing actual and desired output")
+			Expect(string(wantOut)).To(Equal(string(objOut)))
+		})
+	}
+})
diff --git a/pkg/schemapatcher/internal/yaml/nested_test.go b/pkg/schemapatcher/internal/yaml/nested_test.go
new file mode 100644
index 00000000..54a76555
--- /dev/null
+++ b/pkg/schemapatcher/internal/yaml/nested_test.go
@@ -0,0 +1,112 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    htcp://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package yaml
+
+import (
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+var _ = Describe("GetNode", func() {
+	tests := []struct {
+		name    string
+		obj     interface{}
+		path    []string
+		want    interface{}
+		found   bool
+		wantErr bool
+	}{
+		{name: "null",
+			found: true,
+		},
+		{name: "empty",
+			obj:   map[string]interface{}{},
+			found: true,
+			want:  map[string]interface{}{},
+		},
+		{name: "non-empty, wrong path",
+			obj: map[string]interface{}{
+				"bar": int64(42),
+			},
+			path: []string{"foo"},
+		},
+		{name: "non-empty, right path",
+			obj: map[string]interface{}{
+				"bar": int64(42),
+			},
+			path:  []string{"bar"},
+			want:  int64(42),
+			found: true,
+		},
+		{name: "non-empty, long path",
+			obj: map[string]interface{}{
+				"foo": map[string]interface{}{
+					"bar": int64(42),
+				},
+			},
+			path:  []string{"foo", "bar"},
+			want:  int64(42),
+			found: true,
+		},
+		{name: "invalid type",
+			obj: map[string]interface{}{
+				"foo": []interface{}{int64(42)},
+			},
+			path:    []string{"foo", "bar"},
+			wantErr: true,
+			found:   false,
+		},
+	}
+
+	for i := range tests {
+		tc := tests[i]
+		It("should support "+tc.name, func() {
+			By("setting up the test objects and desired values")
+			obj, err := ToYAML(tc.obj)
+			Expect(err).NotTo(HaveOccurred())
+			want, err := ToYAML(tc.want)
+			Expect(err).NotTo(HaveOccurred())
+			// remove the document node
+			want, _, _ = asCloseAsPossible(want)
+
+			By("calling GetNode")
+			node, found, err := GetNode(obj, tc.path...)
+			if tc.wantErr {
+				Expect(err).To(HaveOccurred())
+				return // don't check output value if there's an error
+			}
+
+			Expect(err).NotTo(HaveOccurred())
+
+			// remove line/column info to compare
+			if node != nil {
+				node.Line = 0
+				node.Column = 0
+			}
+			if want != nil {
+				want.Line = 0
+				want.Column = 0
+			}
+
+			By("confirming that the returned node is as we expect it")
+			Expect(found).To(Equal(tc.found))
+			if found {
+				Expect(node).To(Equal(want))
+			}
+		})
+	}
+})
diff --git a/pkg/schemapatcher/internal/yaml/set_test.go b/pkg/schemapatcher/internal/yaml/set_test.go
new file mode 100644
index 00000000..e6d8bf6f
--- /dev/null
+++ b/pkg/schemapatcher/internal/yaml/set_test.go
@@ -0,0 +1,120 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    htcp://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package yaml
+
+import (
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+	"gopkg.in/yaml.v3"
+)
+
+var _ = Describe("SetNode", func() {
+	tests := []struct {
+		name    string
+		obj     interface{}
+		value   interface{}
+		path    []string
+		want    interface{}
+		wantErr bool
+	}{
+		{name: "null"},
+		{name: "empty, null value",
+			obj:   map[string]interface{}{},
+			value: nil,
+			want:  nil,
+		},
+		{name: "empty, non-null value",
+			obj:   map[string]interface{}{},
+			value: int64(64),
+			want:  int64(64),
+		},
+		{name: "non-empty, unknown path",
+			obj: map[string]interface{}{
+				"bar": int64(42),
+			},
+			value: "foo",
+			path:  []string{"foo"},
+			want: map[string]interface{}{
+				"bar": int64(42),
+				"foo": "foo",
+			},
+		},
+		{name: "non-empty, existing path",
+			obj: map[string]interface{}{
+				"bar": int64(42),
+			},
+			value: "foo",
+			path:  []string{"bar"},
+			want: map[string]interface{}{
+				"bar": "foo",
+			},
+		},
+		{name: "non-empty, long path",
+			obj: map[string]interface{}{
+				"foo": map[string]interface{}{
+					"bar": int64(42),
+				},
+			},
+			value: "foo",
+			path:  []string{"foo", "bar"},
+			want: map[string]interface{}{
+				"foo": map[string]interface{}{
+					"bar": "foo",
+				},
+			},
+		},
+		{name: "invalid type",
+			obj: map[string]interface{}{
+				"foo": []interface{}{int64(42)},
+			},
+			value:   "foo",
+			path:    []string{"foo", "bar"},
+			wantErr: true,
+		},
+	}
+
+	for i := range tests {
+		tc := tests[i]
+		It("should support "+tc.name, func() {
+			By("setting up the test objects and desired values")
+			obj, err := ToYAML(tc.obj)
+			Expect(err).NotTo(HaveOccurred())
+			value, err := ToYAML(tc.value)
+			Expect(err).NotTo(HaveOccurred())
+			value, _, _ = asCloseAsPossible(value)
+			want, err := ToYAML(tc.want)
+			Expect(err).NotTo(HaveOccurred())
+
+			By("calling SetNode")
+			if tc.wantErr {
+				Expect(SetNode(obj, *value, tc.path...)).NotTo(Succeed())
+				return // don't check output value if there's an error
+			}
+
+			Expect(SetNode(obj, *value, tc.path...)).To(Succeed())
+
+			By("marshalling the desired and actual objects")
+			wantOut, err := yaml.Marshal(want)
+			Expect(err).NotTo(HaveOccurred())
+			objOut, err := yaml.Marshal(obj)
+			Expect(err).NotTo(HaveOccurred())
+
+			By("comparing actual and desired output")
+			Expect(string(wantOut)).To(Equal(string(objOut)))
+		})
+	}
+})
diff --git a/pkg/schemapatcher/internal/yaml/yaml_suite_test.go b/pkg/schemapatcher/internal/yaml/yaml_suite_test.go
new file mode 100644
index 00000000..972ca02b
--- /dev/null
+++ b/pkg/schemapatcher/internal/yaml/yaml_suite_test.go
@@ -0,0 +1,29 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package yaml
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+func TestYAMLEditing(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "In-place YAML Operations Suite")
+}
diff --git a/pkg/schemapatcher/testdata/Makefile b/pkg/schemapatcher/testdata/Makefile
new file mode 100644
index 00000000..4b0d550b
--- /dev/null
+++ b/pkg/schemapatcher/testdata/Makefile
@@ -0,0 +1,6 @@
+all:
+	go run -v sigs.k8s.io/controller-tools/cmd/controller-gen schemapatch:manifests=./manifests output:dir=./expected paths=./apis/...
+	egrep -v -- "- foo" < expected/kubebuilder-example-crd.yaml > manifests/kubebuilder-example-crd.yaml
+	egrep -v -- "- foo" < expected/legacy-example-crd.yaml > manifests/legacy-example-crd.yaml
+
+.PHONY: all
diff --git a/pkg/schemapatcher/testdata/README.md b/pkg/schemapatcher/testdata/README.md
new file mode 100644
index 00000000..68b66d39
--- /dev/null
+++ b/pkg/schemapatcher/testdata/README.md
@@ -0,0 +1,25 @@
+# CRD Schema Patcher Integration Test testdata
+
+This contains a tiny module used for testdata for the CRD Schema Patcher
+integration test.  The directory should always be called testdata, so Go
+treats it specially.
+
+The types in `apis/<somepkg>` represent different CRD schema versions that
+are either expected to change or not change.
+
+The `kubebuilder` API group contains types that look like modern
+KubeBuilder-generated types, while the `legacy` API group contains types
+that look like core k8s/legacy kubebuilder types.
+
+The `manifests` directory contains input manifests that will be patched,
+while `expected` contains expected output from the patching process.
+
+It's *highly* unlikely that the generated expected manifests will
+ever change from these -- if they do, you've probably broken something.
+Nonetheless, you can regenerate output using `make`.
+
+Make sure you review the diff to ensure that it only contains the desired
+changes!
+
+If you didn't add a new marker and this output changes, make sure you have
+a good explanation for why generated output needs to change!
diff --git a/pkg/schemapatcher/testdata/apis/kubebuilder/v1/doc.go b/pkg/schemapatcher/testdata/apis/kubebuilder/v1/doc.go
new file mode 100644
index 00000000..c28ea54f
--- /dev/null
+++ b/pkg/schemapatcher/testdata/apis/kubebuilder/v1/doc.go
@@ -0,0 +1,21 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// +kubebuilder:object:generate=true
+// +groupName=kubebuilder.schemapatcher.controller-tools.sigs.k8s.io
+
+// Package v1 is the v1 version of the API. It uses kubebuilder markers.
+package v1
diff --git a/pkg/schemapatcher/testdata/apis/kubebuilder/v1/types_example.go b/pkg/schemapatcher/testdata/apis/kubebuilder/v1/types_example.go
new file mode 100644
index 00000000..9e7b31d4
--- /dev/null
+++ b/pkg/schemapatcher/testdata/apis/kubebuilder/v1/types_example.go
@@ -0,0 +1,47 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// +kubebuilder:object:root=true
+
+// Example is a kind with schema changes.
+type Example struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+	// +required
+	Spec ExampleSpec `json:"spec"`
+}
+
+type ExampleSpec struct {
+	// foo contains foo.
+	Foo string `json:"foo"`
+	// foo contains foo.
+	Bar string `json:"bar"`
+}
+
+// +kubebuilder:object:root=true
+
+// ExampleList contains a list of Example.
+type ExampleList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata"`
+	Items           []Example `json:"items"`
+}
diff --git a/pkg/schemapatcher/testdata/apis/kubebuilder/v1/types_unchanged.go b/pkg/schemapatcher/testdata/apis/kubebuilder/v1/types_unchanged.go
new file mode 100644
index 00000000..ca4f3cd5
--- /dev/null
+++ b/pkg/schemapatcher/testdata/apis/kubebuilder/v1/types_unchanged.go
@@ -0,0 +1,47 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// +kubebuilder:object:root=true
+
+// Unchanged is a kind without schema changes.
+type Unchanged struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+	// +required
+	Spec UnchangedSpec `json:"spec"`
+}
+
+type UnchangedSpec struct {
+	// foo contains foo.
+	Foo string `json:"foo"`
+	// foo contains foo.
+	Bar string `json:"bar"`
+}
+
+// +kubebuilder:object:root=true
+
+// UnchangedList contains a list of Unchanged.
+type UnchangedList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata"`
+	Items           []Example `json:"items"`
+}
diff --git a/pkg/schemapatcher/testdata/apis/legacy/v1/doc.go b/pkg/schemapatcher/testdata/apis/legacy/v1/doc.go
new file mode 100644
index 00000000..92939856
--- /dev/null
+++ b/pkg/schemapatcher/testdata/apis/legacy/v1/doc.go
@@ -0,0 +1,23 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// +k8s:deepcopy-gen=package,register
+// +k8s:defaulter-gen=TypeMeta
+// +k8s:openapi-gen=true
+// +groupName=legacy.schemapatcher.controller-tools.sigs.k8s.io
+
+// Package v1 is the v1 version of the API. It uses legacy k8s.io/code-generator markers.
+package v1
diff --git a/pkg/schemapatcher/testdata/apis/legacy/v1/types_example.go b/pkg/schemapatcher/testdata/apis/legacy/v1/types_example.go
new file mode 100644
index 00000000..c62598f8
--- /dev/null
+++ b/pkg/schemapatcher/testdata/apis/legacy/v1/types_example.go
@@ -0,0 +1,49 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// +genclient
+// +genclient:nonNamespaced
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+
+// Example is a kind with schema changes.
+type Example struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+	// +required
+	Spec ExampleSpec `json:"spec"`
+}
+
+type ExampleSpec struct {
+	// foo contains foo.
+	Foo string `json:"foo"`
+	// foo contains foo.
+	Bar string `json:"bar"`
+}
+
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+
+// ExampleList contains a list of Example.
+type ExampleList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata"`
+	Items           []Example `json:"items"`
+}
diff --git a/pkg/schemapatcher/testdata/apis/legacy/v1/types_unchanged.go b/pkg/schemapatcher/testdata/apis/legacy/v1/types_unchanged.go
new file mode 100644
index 00000000..d3ef983f
--- /dev/null
+++ b/pkg/schemapatcher/testdata/apis/legacy/v1/types_unchanged.go
@@ -0,0 +1,49 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// +genclient
+// +genclient:nonNamespaced
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+
+// Unchanged is a kind without schema changes.
+type Unchanged struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+	// +required
+	Spec UnchangedSpec `json:"spec"`
+}
+
+type UnchangedSpec struct {
+	// foo contains foo.
+	Foo string `json:"foo"`
+	// foo contains foo.
+	Bar string `json:"bar"`
+}
+
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+
+// UnchangedList contains a list of Unchanged.
+type UnchangedList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata"`
+	Items           []Example `json:"items"`
+}
diff --git a/pkg/schemapatcher/testdata/expected/kubebuilder-example-crd.yaml b/pkg/schemapatcher/testdata/expected/kubebuilder-example-crd.yaml
new file mode 100644
index 00000000..c491dcdc
--- /dev/null
+++ b/pkg/schemapatcher/testdata/expected/kubebuilder-example-crd.yaml
@@ -0,0 +1,44 @@
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+  name: example.kubebuilder.schemapatcher.controller-tools.sigs.k8s.io
+spec:
+  group: kubebuilder.schemapatcher.controller-tools.sigs.k8s.io
+  scope: Cluster
+  names:
+    kind: Example
+    singular: example
+    plural: examples
+    listKind: ExampleList
+  version: v1
+  validation:
+    openAPIV3Schema:
+      description: Example is a kind with schema changes.
+      type: object
+      required:
+      - spec
+      properties:
+        apiVersion:
+          description: 'APIVersion defines the versioned schema of this representation
+            of an object. Servers should convert recognized schemas to the latest
+            internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#resources'
+          type: string
+        kind:
+          description: 'Kind is a string value representing the REST resource this
+            object represents. Servers may infer this from the endpoint the client
+            submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#types-kinds'
+          type: string
+        metadata:
+          type: object
+        spec:
+          type: object
+          required:
+          - bar
+          - foo
+          properties:
+            bar:
+              description: foo contains foo.
+              type: string
+            foo:
+              description: foo contains foo.
+              type: string
diff --git a/pkg/schemapatcher/testdata/expected/kubebuilder-unchanged-crd.yaml b/pkg/schemapatcher/testdata/expected/kubebuilder-unchanged-crd.yaml
new file mode 100644
index 00000000..ec4d2a10
--- /dev/null
+++ b/pkg/schemapatcher/testdata/expected/kubebuilder-unchanged-crd.yaml
@@ -0,0 +1,44 @@
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+  name: unchanged.kubebuilder.schemapatcher.controller-tools.sigs.k8s.io
+spec:
+  group: kubebuilder.schemapatcher.controller-tools.sigs.k8s.io
+  scope: Cluster
+  names:
+    kind: Unchanged
+    singular: unchanged
+    plural: unchangeds
+    listKind: UnchangedList
+  version: v1
+  validation:
+    openAPIV3Schema:
+      description: Unchanged is a kind without schema changes.
+      type: object
+      required:
+      - spec
+      properties:
+        apiVersion:
+          description: 'APIVersion defines the versioned schema of this representation
+            of an object. Servers should convert recognized schemas to the latest
+            internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#resources'
+          type: string
+        kind:
+          description: 'Kind is a string value representing the REST resource this
+            object represents. Servers may infer this from the endpoint the client
+            submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#types-kinds'
+          type: string
+        metadata:
+          type: object
+        spec:
+          type: object
+          required:
+          - bar
+          - foo
+          properties:
+            bar:
+              description: foo contains foo.
+              type: string
+            foo:
+              description: foo contains foo.
+              type: string
diff --git a/pkg/schemapatcher/testdata/expected/legacy-example-crd.yaml b/pkg/schemapatcher/testdata/expected/legacy-example-crd.yaml
new file mode 100644
index 00000000..df59aa88
--- /dev/null
+++ b/pkg/schemapatcher/testdata/expected/legacy-example-crd.yaml
@@ -0,0 +1,49 @@
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+  name: example.legacy.schemapatcher.controller-tools.sigs.k8s.io
+
+  # ensure that comments and annotations are left in place
+  annotations:
+    firstkey: firstval
+    secondkey: secondval
+spec:
+  group: legacy.schemapatcher.controller-tools.sigs.k8s.io
+  scope: Cluster
+  names:
+    kind: Example
+    singular: example
+    plural: examples
+    listKind: ExampleList
+  version: v1
+  validation:
+    openAPIV3Schema:
+      description: Example is a kind with schema changes.
+      type: object
+      required:
+      - spec
+      properties:
+        apiVersion:
+          description: 'APIVersion defines the versioned schema of this representation
+            of an object. Servers should convert recognized schemas to the latest
+            internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#resources'
+          type: string
+        kind:
+          description: 'Kind is a string value representing the REST resource this
+            object represents. Servers may infer this from the endpoint the client
+            submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#types-kinds'
+          type: string
+        metadata:
+          type: object
+        spec:
+          type: object
+          required:
+          - bar
+          - foo
+          properties:
+            bar:
+              description: foo contains foo.
+              type: string
+            foo:
+              description: foo contains foo.
+              type: string
diff --git a/pkg/schemapatcher/testdata/expected/legacy-unchanged-crd.yaml b/pkg/schemapatcher/testdata/expected/legacy-unchanged-crd.yaml
new file mode 100644
index 00000000..3caa090f
--- /dev/null
+++ b/pkg/schemapatcher/testdata/expected/legacy-unchanged-crd.yaml
@@ -0,0 +1,50 @@
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+  name: unchanged.legacy.schemapatcher.controller-tools.sigs.k8s.io
+
+  # ensure that comments and annotations are left in place
+  # (note the space above this comment -- go-yaml isn't perfect preseving whitespace)
+  annotations:
+    firstkey: firstval
+    secondkey: secondval
+spec:
+  group: legacy.schemapatcher.controller-tools.sigs.k8s.io
+  scope: Cluster
+  names:
+    kind: Unchanged
+    singular: unchanged
+    plural: unchangeds
+    listKind: UnchangedList
+  version: v1
+  validation:
+    openAPIV3Schema:
+      description: Unchanged is a kind without schema changes.
+      type: object
+      required:
+      - spec
+      properties:
+        apiVersion:
+          description: 'APIVersion defines the versioned schema of this representation
+            of an object. Servers should convert recognized schemas to the latest
+            internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#resources'
+          type: string
+        kind:
+          description: 'Kind is a string value representing the REST resource this
+            object represents. Servers may infer this from the endpoint the client
+            submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#types-kinds'
+          type: string
+        metadata:
+          type: object
+        spec:
+          type: object
+          required:
+          - bar
+          - foo
+          properties:
+            bar:
+              description: foo contains foo.
+              type: string
+            foo:
+              description: foo contains foo.
+              type: string
diff --git a/pkg/schemapatcher/testdata/expected/other-crd.yaml b/pkg/schemapatcher/testdata/expected/other-crd.yaml
new file mode 100644
index 00000000..da1b1940
--- /dev/null
+++ b/pkg/schemapatcher/testdata/expected/other-crd.yaml
@@ -0,0 +1,13 @@
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+  name: other.schemapatcher.controller-tools.sigs.k8s.io
+spec:
+  group: schemapatcher.controller-tools.sigs.k8s.io
+  scope: Cluster
+  names:
+    kind: Other
+    singular: other
+    plural: others
+    listKind: OtherList
+  version: v1
diff --git a/pkg/schemapatcher/testdata/go.mod b/pkg/schemapatcher/testdata/go.mod
new file mode 100644
index 00000000..f11e482e
--- /dev/null
+++ b/pkg/schemapatcher/testdata/go.mod
@@ -0,0 +1,11 @@
+module testdata.kubebuilder.io/inplaceschema
+
+go 1.12
+
+require (
+	k8s.io/apimachinery v0.0.0-20190806215851-162a2dabc72f
+	sigs.k8s.io/controller-tools v0.0.0-00010101000000-000000000000 // indirect
+)
+
+// use the current copy of controller-tools
+replace sigs.k8s.io/controller-tools => ../../..
diff --git a/pkg/schemapatcher/testdata/go.sum b/pkg/schemapatcher/testdata/go.sum
new file mode 100644
index 00000000..575376e1
--- /dev/null
+++ b/pkg/schemapatcher/testdata/go.sum
@@ -0,0 +1,126 @@
+github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
+github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
+github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
+github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM=
+github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc=
+github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
+github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
+github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
+github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0=
+github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg=
+github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc=
+github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I=
+github.com/gobuffalo/flect v0.1.5 h1:xpKq9ap8MbYfhuPCF0dBH854Gp9CxZjr/IocxELFflo=
+github.com/gobuffalo/flect v0.1.5/go.mod h1:W3K3X9ksuZfir8f/LrfVtWmCDQFfayuylOJ7sz/Fj80=
+github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
+github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d h1:3PaI8p3seN09VjbTYC/QWlUZdZ1qS1zGjy7LH2Wt07I=
+github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
+github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI=
+github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI=
+github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/gnostic v0.0.0-20170426233943-68f4ded48ba9/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
+github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
+github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
+github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
+github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
+github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
+github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
+github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
+github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v0.0.0-20180320133207-05fbef0ca5da/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
+github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
+github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8=
+github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
+github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
+github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190613194153-d28f0bde5980 h1:dfGZHvZk057jK2MCeWus/TowKpJ8y4AmooUzdBSR9GU=
+golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f h1:25KHgbfyiSm6vwQLbM3zZIe1v9p/3ea4Rz+nnM5K/i4=
+golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190501045030-23463209683d h1:D7DVZUZEUgsSIDTivnUtVeGfN5AvhDIKtdIZAqx0ieE=
+golang.org/x/tools v0.0.0-20190501045030-23463209683d/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/inf.v0 v0.9.0 h1:3zYtXIO92bvsdS3ggAdA8Gb4Azj0YU+TVY1uGYNFA8o=
+gopkg.in/inf.v0 v0.9.0/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20190709130402-674ba3eaed22 h1:0efs3hwEZhFKsCoP8l6dDB1AZWMgnEl3yWXWRZTOaEA=
+gopkg.in/yaml.v3 v3.0.0-20190709130402-674ba3eaed22/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+k8s.io/api v0.0.0-20190409021203-6e4e0e4f393b h1:aBGgKJUM9Hk/3AE8WaZIApnTxG35kbuQba2w+SXqezo=
+k8s.io/api v0.0.0-20190409021203-6e4e0e4f393b/go.mod h1:iuAfoD4hCxJ8Onx9kaTIt30j7jUFS00AXQi6QMi99vA=
+k8s.io/apiextensions-apiserver v0.0.0-20190409022649-727a075fdec8 h1:q1Qvjzs/iEdXF6A1a8H3AKVFDzJNcJn3nXMs6R6qFtA=
+k8s.io/apiextensions-apiserver v0.0.0-20190409022649-727a075fdec8/go.mod h1:IxkesAMoaCRoLrPJdZNZUQp9NfZnzqaVzLhb2VEQzXE=
+k8s.io/apimachinery v0.0.0-20190404173353-6a84e37a896d/go.mod h1:ccL7Eh7zubPUSh9A3USN90/OzHNSVN6zxzde07TDCL0=
+k8s.io/apimachinery v0.0.0-20190806215851-162a2dabc72f h1:AMgWgCgCg340fSnKX3DiE5bTAWKMT7WzBxA23r863nw=
+k8s.io/apimachinery v0.0.0-20190806215851-162a2dabc72f/go.mod h1:+ntn62igV2hyNj7/0brOvXSMONE2KxcePkSxK7/9FFQ=
+k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
+k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
+k8s.io/klog v0.2.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
+k8s.io/klog v0.3.1 h1:RVgyDHY/kFKtLqh67NvEWIgkMneNoIrdkN0CxDSQc68=
+k8s.io/klog v0.3.1/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
+k8s.io/kube-openapi v0.0.0-20190709113604-33be087ad058/go.mod h1:nfDlWeOsu3pUf4yWGL+ERqohP4YsZcBJXWMK+gkzOA4=
+sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI=
+sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs=
+sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
diff --git a/pkg/schemapatcher/testdata/manifests/kubebuilder-example-crd.yaml b/pkg/schemapatcher/testdata/manifests/kubebuilder-example-crd.yaml
new file mode 100644
index 00000000..a7342e8a
--- /dev/null
+++ b/pkg/schemapatcher/testdata/manifests/kubebuilder-example-crd.yaml
@@ -0,0 +1,43 @@
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+  name: example.kubebuilder.schemapatcher.controller-tools.sigs.k8s.io
+spec:
+  group: kubebuilder.schemapatcher.controller-tools.sigs.k8s.io
+  scope: Cluster
+  names:
+    kind: Example
+    singular: example
+    plural: examples
+    listKind: ExampleList
+  version: v1
+  validation:
+    openAPIV3Schema:
+      description: Example is a kind with schema changes.
+      type: object
+      required:
+      - spec
+      properties:
+        apiVersion:
+          description: 'APIVersion defines the versioned schema of this representation
+            of an object. Servers should convert recognized schemas to the latest
+            internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#resources'
+          type: string
+        kind:
+          description: 'Kind is a string value representing the REST resource this
+            object represents. Servers may infer this from the endpoint the client
+            submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#types-kinds'
+          type: string
+        metadata:
+          type: object
+        spec:
+          type: object
+          required:
+          - bar
+          properties:
+            bar:
+              description: foo contains foo.
+              type: string
+            foo:
+              description: foo contains foo.
+              type: string
diff --git a/pkg/schemapatcher/testdata/manifests/kubebuilder-unchanged-crd.yaml b/pkg/schemapatcher/testdata/manifests/kubebuilder-unchanged-crd.yaml
new file mode 100644
index 00000000..ec4d2a10
--- /dev/null
+++ b/pkg/schemapatcher/testdata/manifests/kubebuilder-unchanged-crd.yaml
@@ -0,0 +1,44 @@
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+  name: unchanged.kubebuilder.schemapatcher.controller-tools.sigs.k8s.io
+spec:
+  group: kubebuilder.schemapatcher.controller-tools.sigs.k8s.io
+  scope: Cluster
+  names:
+    kind: Unchanged
+    singular: unchanged
+    plural: unchangeds
+    listKind: UnchangedList
+  version: v1
+  validation:
+    openAPIV3Schema:
+      description: Unchanged is a kind without schema changes.
+      type: object
+      required:
+      - spec
+      properties:
+        apiVersion:
+          description: 'APIVersion defines the versioned schema of this representation
+            of an object. Servers should convert recognized schemas to the latest
+            internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#resources'
+          type: string
+        kind:
+          description: 'Kind is a string value representing the REST resource this
+            object represents. Servers may infer this from the endpoint the client
+            submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#types-kinds'
+          type: string
+        metadata:
+          type: object
+        spec:
+          type: object
+          required:
+          - bar
+          - foo
+          properties:
+            bar:
+              description: foo contains foo.
+              type: string
+            foo:
+              description: foo contains foo.
+              type: string
diff --git a/pkg/schemapatcher/testdata/manifests/legacy-example-crd.yaml b/pkg/schemapatcher/testdata/manifests/legacy-example-crd.yaml
new file mode 100644
index 00000000..6fb76a96
--- /dev/null
+++ b/pkg/schemapatcher/testdata/manifests/legacy-example-crd.yaml
@@ -0,0 +1,48 @@
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+  name: example.legacy.schemapatcher.controller-tools.sigs.k8s.io
+
+  # ensure that comments and annotations are left in place
+  annotations:
+    firstkey: firstval
+    secondkey: secondval
+spec:
+  group: legacy.schemapatcher.controller-tools.sigs.k8s.io
+  scope: Cluster
+  names:
+    kind: Example
+    singular: example
+    plural: examples
+    listKind: ExampleList
+  version: v1
+  validation:
+    openAPIV3Schema:
+      description: Example is a kind with schema changes.
+      type: object
+      required:
+      - spec
+      properties:
+        apiVersion:
+          description: 'APIVersion defines the versioned schema of this representation
+            of an object. Servers should convert recognized schemas to the latest
+            internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#resources'
+          type: string
+        kind:
+          description: 'Kind is a string value representing the REST resource this
+            object represents. Servers may infer this from the endpoint the client
+            submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#types-kinds'
+          type: string
+        metadata:
+          type: object
+        spec:
+          type: object
+          required:
+          - bar
+          properties:
+            bar:
+              description: foo contains foo.
+              type: string
+            foo:
+              description: foo contains foo.
+              type: string
diff --git a/pkg/schemapatcher/testdata/manifests/legacy-unchanged-crd.yaml b/pkg/schemapatcher/testdata/manifests/legacy-unchanged-crd.yaml
new file mode 100644
index 00000000..3caa090f
--- /dev/null
+++ b/pkg/schemapatcher/testdata/manifests/legacy-unchanged-crd.yaml
@@ -0,0 +1,50 @@
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+  name: unchanged.legacy.schemapatcher.controller-tools.sigs.k8s.io
+
+  # ensure that comments and annotations are left in place
+  # (note the space above this comment -- go-yaml isn't perfect preseving whitespace)
+  annotations:
+    firstkey: firstval
+    secondkey: secondval
+spec:
+  group: legacy.schemapatcher.controller-tools.sigs.k8s.io
+  scope: Cluster
+  names:
+    kind: Unchanged
+    singular: unchanged
+    plural: unchangeds
+    listKind: UnchangedList
+  version: v1
+  validation:
+    openAPIV3Schema:
+      description: Unchanged is a kind without schema changes.
+      type: object
+      required:
+      - spec
+      properties:
+        apiVersion:
+          description: 'APIVersion defines the versioned schema of this representation
+            of an object. Servers should convert recognized schemas to the latest
+            internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#resources'
+          type: string
+        kind:
+          description: 'Kind is a string value representing the REST resource this
+            object represents. Servers may infer this from the endpoint the client
+            submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#types-kinds'
+          type: string
+        metadata:
+          type: object
+        spec:
+          type: object
+          required:
+          - bar
+          - foo
+          properties:
+            bar:
+              description: foo contains foo.
+              type: string
+            foo:
+              description: foo contains foo.
+              type: string
diff --git a/pkg/schemapatcher/testdata/manifests/other-crd.yaml b/pkg/schemapatcher/testdata/manifests/other-crd.yaml
new file mode 100644
index 00000000..da1b1940
--- /dev/null
+++ b/pkg/schemapatcher/testdata/manifests/other-crd.yaml
@@ -0,0 +1,13 @@
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+  name: other.schemapatcher.controller-tools.sigs.k8s.io
+spec:
+  group: schemapatcher.controller-tools.sigs.k8s.io
+  scope: Cluster
+  names:
+    kind: Other
+    singular: other
+    plural: others
+    listKind: OtherList
+  version: v1