Skip to content

Commit 1247e96

Browse files
committed
Add initialCRDPatches option to onUpdate tests
1 parent 718d8a3 commit 1247e96

File tree

3 files changed

+121
-4
lines changed

3 files changed

+121
-4
lines changed

tests/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,35 @@ A full example is included below:
237237
immutableField: bar
238238
expectedStatusError: "status.immutableField: Invalid value: \"string\": immutableField is immutable"
239239
```
240+
241+
#### Testing validation ratcheting
242+
243+
Kubernetes now supports [validation ratcheting][validation-ratcheting].
244+
This means that we can now evolve the validations of APIs over time, without immediately breaking stored APIs.
245+
Note, any changes to validations that may be breaking still need careful consideration, this is not a 'get out of jail free' card.
246+
247+
Ratcheting can be tested using `onUpdate` tests and the `initialCRDPatches` option.
248+
`initialCRDPatches` is a list of JSON Object Patches ([RFC6902][rfc6902]) that will be applied temporarily to the CRD prior to the
249+
initial object being created.
250+
This allows you to revert a newer change to an API validation, apply an object that would be invalid with the newer validation,
251+
and then test how the object behaves with the new, current schema.
252+
253+
For example, if a field does not include a maximum, and we decide to enforce a new maximum, a patch such like below could be used
254+
to remove the maximum temporarily to then create an object which exceeds the new maximum.
255+
256+
```
257+
onUpdate:
258+
- name: ...
259+
initialCRDPatches:
260+
- op: remove
261+
path: /spec/versions/0/schema/openAPIV3Schema/properties/spec/properties/fieldWithNewMaxLength/maximum # Maximum was not originally set
262+
...
263+
```
264+
265+
Once the patch is applied, three tests should be added for each ratcheting validation:
266+
1. Test that other values can be updated, while the invalid value is persisted
267+
1. Test that the value itself cannot be updated to an alternative, newly invalid value (e.g. making the value longer in this case)
268+
1. Test that the value can be updated to a valid value (e.g. in this case, a value shorted than the new maximum)
269+
270+
[validation-ratcheting]: https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#validation-ratcheting
271+
[rfc6902]: https://datatracker.ietf.org/doc/html/rfc6902

tests/generator.go

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package tests
22

33
import (
4+
"bytes"
45
"fmt"
56
"os"
67
"path/filepath"
78
"strings"
89

910
. "github.com/onsi/ginkgo/v2"
1011
. "github.com/onsi/gomega"
12+
yamlpatch "github.com/vmware-archive/yaml-patch"
1113

1214
"github.com/ghodss/yaml"
1315

@@ -139,7 +141,7 @@ func GenerateTestSuite(suiteSpec SuiteSpec) {
139141
})
140142

141143
generateOnCreateTable(suiteSpec.Tests.OnCreate)
142-
generateOnUpdateTable(suiteSpec.Tests.OnUpdate)
144+
generateOnUpdateTable(suiteSpec.Tests.OnUpdate, crdFilename)
143145
})
144146
}
145147
}
@@ -200,9 +202,10 @@ func generateOnCreateTable(onCreateTests []OnCreateTestSpec) {
200202

201203
// generateOnUpdateTable generates a table of tests from the defined OnUpdate tests
202204
// within the test suite test spec.
203-
func generateOnUpdateTable(onUpdateTests []OnUpdateTestSpec) {
205+
func generateOnUpdateTable(onUpdateTests []OnUpdateTestSpec, crdFileName string) {
204206
type onUpdateTableInput struct {
205207
featureGate string
208+
crdPatches []Patch
206209
initial []byte
207210
updated []byte
208211
expected []byte
@@ -211,6 +214,24 @@ func generateOnUpdateTable(onUpdateTests []OnUpdateTestSpec) {
211214
}
212215

213216
var assertOnUpdate interface{} = func(in onUpdateTableInput) {
217+
var originalCRDObjectKey client.ObjectKey
218+
var originalCRDSpec apiextensionsv1.CustomResourceDefinitionSpec
219+
220+
if len(in.crdPatches) > 0 {
221+
patchedCRD, err := getPatchedCRD(crdFileName, in.crdPatches)
222+
Expect(err).ToNot(HaveOccurred(), "could not load patched crd")
223+
224+
originalCRDObjectKey = objectKey(patchedCRD)
225+
226+
originalCRD := &apiextensionsv1.CustomResourceDefinition{}
227+
Expect(k8sClient.Get(ctx, originalCRDObjectKey, originalCRD))
228+
229+
originalCRDSpec = *originalCRD.Spec.DeepCopy()
230+
originalCRD.Spec = patchedCRD.Spec
231+
232+
Expect(k8sClient.Update(ctx, originalCRD)).To(Succeed(), "failed updating patched CRD schema")
233+
}
234+
214235
initialObj, err := newUnstructuredFrom(in.initial)
215236
Expect(err).ToNot(HaveOccurred(), "initial data should be a valid Kubernetes YAML resource")
216237

@@ -227,6 +248,15 @@ func generateOnUpdateTable(onUpdateTests []OnUpdateTestSpec) {
227248
Expect(k8sClient.Status().Update(ctx, initialObj)).ToNot(HaveOccurred(), "initial object status should update successfully")
228249
}
229250

251+
if len(in.crdPatches) > 0 {
252+
originalCRD := &apiextensionsv1.CustomResourceDefinition{}
253+
Expect(k8sClient.Get(ctx, originalCRDObjectKey, originalCRD))
254+
255+
originalCRD.Spec = originalCRDSpec
256+
257+
Expect(k8sClient.Update(ctx, originalCRD)).To(Succeed())
258+
}
259+
230260
// Fetch the object we just created from the API.
231261
gotObj := newEmptyUnstructuredFrom(initialObj)
232262
Expect(k8sClient.Get(ctx, objectKey(initialObj), gotObj))
@@ -250,7 +280,7 @@ func generateOnUpdateTable(onUpdateTests []OnUpdateTestSpec) {
250280
Expect(err).To(MatchError(ContainSubstring(in.expectedError)))
251281
return
252282
}
253-
Expect(err).ToNot(HaveOccurred())
283+
Expect(err).ToNot(HaveOccurred(), "unexpected error updating spec")
254284

255285
if updatedObjStatus != nil {
256286
Expect(unstructured.SetNestedField(updatedObj.Object, updatedObjStatus, "status")).To(Succeed(), "should be able to restore updated status")
@@ -260,7 +290,7 @@ func generateOnUpdateTable(onUpdateTests []OnUpdateTestSpec) {
260290
Expect(err).To(MatchError(ContainSubstring(in.expectedStatusError)))
261291
return
262292
}
263-
Expect(err).ToNot(HaveOccurred())
293+
Expect(err).ToNot(HaveOccurred(), "unexpected error updating status")
264294
}
265295

266296
Expect(k8sClient.Get(ctx, objectKey(initialObj), gotObj))
@@ -282,6 +312,7 @@ func generateOnUpdateTable(onUpdateTests []OnUpdateTestSpec) {
282312
// Convert the test specs into table entries
283313
for _, testEntry := range onUpdateTests {
284314
tableEntries = append(tableEntries, Entry(testEntry.Name, onUpdateTableInput{
315+
crdPatches: testEntry.InitialCRDPatches,
285316
initial: []byte(testEntry.Initial),
286317
updated: []byte(testEntry.Updated),
287318
expected: []byte(testEntry.Expected),
@@ -453,3 +484,35 @@ func getSuiteSpecTestVersion(suiteSpec SuiteSpec) (string, error) {
453484

454485
return version, nil
455486
}
487+
488+
func getPatchedCRD(crdFileName string, patches []Patch) (*apiextensionsv1.CustomResourceDefinition, error) {
489+
patch := yamlpatch.Patch{}
490+
491+
for _, p := range patches {
492+
patch = append(patch, yamlpatch.Operation{
493+
Op: yamlpatch.Op(p.Op),
494+
Path: yamlpatch.OpPath(p.Path),
495+
Value: yamlpatch.NewNode(p.Value),
496+
})
497+
}
498+
499+
baseDoc, err := os.ReadFile(crdFileName)
500+
if err != nil {
501+
return nil, fmt.Errorf("could not read file %q: %w", crdFileName, err)
502+
}
503+
504+
patchedDoc, err := patch.Apply(baseDoc)
505+
if err != nil {
506+
return nil, fmt.Errorf("could not apply patch: %w", err)
507+
}
508+
509+
placeholderWrapper := yamlpatch.NewPlaceholderWrapper("{{", "}}")
510+
patchedData := bytes.NewBuffer(placeholderWrapper.Unwrap(patchedDoc))
511+
512+
crd := &apiextensionsv1.CustomResourceDefinition{}
513+
if err := yaml.Unmarshal(patchedData.Bytes(), crd); err != nil {
514+
return nil, fmt.Errorf("could not unmarshal CRD: %w", err)
515+
}
516+
517+
return crd, nil
518+
}

tests/types.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ type OnUpdateTestSpec struct {
6767
// Name is the name of this test case.
6868
Name string `json:"name"`
6969

70+
// InitialCRDPatches is a list of YAML patches to apply to the CRD before applying
71+
// the initial version of the resource.
72+
// Once the initial version has been applied, the CRD will be restored to its
73+
// original state before the updated object is applied.
74+
// This can be used to test ratcheting validation of CRD schema changes over time.
75+
InitialCRDPatches []Patch `json:"initialCRDPatches"`
76+
7077
// Initial is a literal string containing the initial YAML content from which to
7178
// create the resource.
7279
// Note `apiVersion` and `kind` fields are required though `metadata` can be omitted.
@@ -93,3 +100,18 @@ type OnUpdateTestSpec struct {
93100
// Typically this will vary in `spec` only test to test.
94101
Expected string `json:"expected"`
95102
}
103+
104+
// Patch represents a single operation to be applied to a YAML document.
105+
// It follows the JSON Patch format as defined in RFC 6902.
106+
// Each patch operation is atomic and can be used to modify the structure
107+
// or content of a YAML document.
108+
type Patch struct {
109+
// Op is the operation to be performed. Common operations include "add", "remove", "replace", "move", "copy", and "test".
110+
Op string `json:"op"`
111+
112+
// Path is a JSON Pointer that indicates the location in the YAML document where the operation is to be performed.
113+
Path string `json:"path"`
114+
115+
// Value is the value to be used within the operation. This field is required for operations like "add" and "replace".
116+
Value *interface{} `json:"value"`
117+
}

0 commit comments

Comments
 (0)