Skip to content

Commit 6e71814

Browse files
authored
Add handling of updates to a field's enum values (#921)
Signed-off-by: everettraven <[email protected]>
1 parent deefc9a commit 6e71814

6 files changed

+1014
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// Copyright 2024 The Carvel Authors.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package crdupgradesafety
5+
6+
import (
7+
"errors"
8+
"fmt"
9+
"reflect"
10+
11+
"github.com/openshift/crd-schema-checker/pkg/manifestcomparators"
12+
v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
13+
"k8s.io/apimachinery/pkg/util/sets"
14+
"k8s.io/apimachinery/pkg/util/validation/field"
15+
)
16+
17+
// ChangeValidation is a function that accepts a FieldDiff
18+
// as a parameter and should return:
19+
// - a boolean representation of whether or not the change
20+
// - an error if the change would be unsafe
21+
// has been fully handled (i.e no additional changes exist)
22+
type ChangeValidation func(diff FieldDiff) (bool, error)
23+
24+
// EnumChangeValidation ensures that:
25+
// - No enums are added to a field that did not previously have
26+
// enum restrictions
27+
// - No enums are removed from a field
28+
// This function returns:
29+
// - A boolean representation of whether or not the change
30+
// has been fully handled (i.e the only change was to enum values)
31+
// - An error if either of the above validations are not satisfied
32+
func EnumChangeValidation(diff FieldDiff) (bool, error) {
33+
// This function resets the enum values for the
34+
// old and new field and compares them to determine
35+
// if there are any additional changes that should be
36+
// handled. Reseting the enum values allows for chained
37+
// evaluations to check if they have handled all the changes
38+
// without having to account for fields other than the ones
39+
// they are designed to handle. This function should only be called when
40+
// returning from this function to prevent unnecessary overwrites of
41+
// these fields.
42+
handled := func() bool {
43+
diff.Old.Enum = []v1.JSON{}
44+
diff.New.Enum = []v1.JSON{}
45+
return reflect.DeepEqual(diff.Old, diff.New)
46+
}
47+
48+
if len(diff.Old.Enum) == 0 && len(diff.New.Enum) > 0 {
49+
return handled(), fmt.Errorf("enums added when there were no enum restrictions previously")
50+
}
51+
52+
oldSet := sets.NewString()
53+
for _, enum := range diff.Old.Enum {
54+
if !oldSet.Has(string(enum.Raw)) {
55+
oldSet.Insert(string(enum.Raw))
56+
}
57+
}
58+
59+
newSet := sets.NewString()
60+
for _, enum := range diff.New.Enum {
61+
if !newSet.Has(string(enum.Raw)) {
62+
newSet.Insert(string(enum.Raw))
63+
}
64+
}
65+
66+
diffSet := oldSet.Difference(newSet)
67+
if diffSet.Len() > 0 {
68+
return handled(), fmt.Errorf("enum values removed: %+v", diffSet.UnsortedList())
69+
}
70+
71+
return handled(), nil
72+
}
73+
74+
// ChangeValidator is a Validation implementation focused on
75+
// handling updates to existing fields in a CRD
76+
type ChangeValidator struct {
77+
// Validations is a slice of ChangeValidations
78+
// to run against each changed field
79+
Validations []ChangeValidation
80+
}
81+
82+
func (cv *ChangeValidator) Name() string {
83+
return "ChangeValidator"
84+
}
85+
86+
// Validate will compare each version in the provided existing and new CRDs.
87+
// Since the ChangeValidator is tailored to handling updates to existing fields in
88+
// each version of a CRD. As such the following is assumed:
89+
// - Validating the removal of versions during an update is handled outside of this
90+
// validator. If a version in the existing version of the CRD does not exist in the new
91+
// version that version of the CRD is skipped in this validator.
92+
// - Removal of existing fields is unsafe. Regardless of whether or not this is handled
93+
// by a validator outside this one, if a field is present in a version provided by the existing CRD
94+
// but not present in the same version provided by the new CRD this validation will fail.
95+
//
96+
// Additionally, any changes that are not validated and handled by the known ChangeValidations
97+
// are deemed as unsafe and returns an error.
98+
func (cv *ChangeValidator) Validate(old, new v1.CustomResourceDefinition) error {
99+
errs := []error{}
100+
for _, version := range old.Spec.Versions {
101+
newVersion := manifestcomparators.GetVersionByName(&new, version.Name)
102+
if newVersion == nil {
103+
// if the new version doesn't exist skip this version
104+
continue
105+
}
106+
flatOld := FlattenSchema(version.Schema.OpenAPIV3Schema)
107+
flatNew := FlattenSchema(newVersion.Schema.OpenAPIV3Schema)
108+
109+
diffs, err := CalculateFlatSchemaDiff(flatOld, flatNew)
110+
if err != nil {
111+
errs = append(errs, fmt.Errorf("calculating schema diff for CRD version %q", version.Name))
112+
continue
113+
}
114+
115+
for field, diff := range diffs {
116+
handled := false
117+
for _, validation := range cv.Validations {
118+
ok, err := validation(diff)
119+
if err != nil {
120+
errs = append(errs, fmt.Errorf("version %q, field %q: %w", version.Name, field, err))
121+
}
122+
if ok {
123+
handled = true
124+
break
125+
}
126+
}
127+
128+
if !handled {
129+
errs = append(errs, fmt.Errorf("version %q, field %q has unknown change, refusing to determine that change is safe", version.Name, field))
130+
}
131+
}
132+
}
133+
134+
if len(errs) > 0 {
135+
return errors.Join(errs...)
136+
}
137+
return nil
138+
}
139+
140+
type FieldDiff struct {
141+
Old *v1.JSONSchemaProps
142+
New *v1.JSONSchemaProps
143+
}
144+
145+
// FlatSchema is a flat representation of a CRD schema.
146+
type FlatSchema map[string]*v1.JSONSchemaProps
147+
148+
// FlattenSchema takes in a CRD version OpenAPIV3Schema and returns
149+
// a flattened representation of it. For example, a CRD with a schema of:
150+
// ```yaml
151+
//
152+
// ...
153+
// spec:
154+
// type: object
155+
// properties:
156+
// foo:
157+
// type: string
158+
// bar:
159+
// type: string
160+
// ...
161+
//
162+
// ```
163+
// would be represented as:
164+
//
165+
// map[string]*v1.JSONSchemaProps{
166+
// "^": {},
167+
// "^.spec": {},
168+
// "^.spec.foo": {},
169+
// "^.spec.bar": {},
170+
// }
171+
//
172+
// where "^" represents the "root" schema
173+
func FlattenSchema(schema *v1.JSONSchemaProps) FlatSchema {
174+
fieldMap := map[string]*v1.JSONSchemaProps{}
175+
176+
manifestcomparators.SchemaHas(schema,
177+
field.NewPath("^"),
178+
field.NewPath("^"),
179+
nil,
180+
func(s *v1.JSONSchemaProps, _, simpleLocation *field.Path, _ []*v1.JSONSchemaProps) bool {
181+
fieldMap[simpleLocation.String()] = s.DeepCopy()
182+
return false
183+
})
184+
185+
return fieldMap
186+
}
187+
188+
// CalculateFlatSchemaDiff finds fields in a FlatSchema that are different
189+
// and returns a mapping of field --> old and new field schemas. If a field
190+
// exists in the old FlatSchema but not the new an empty diff mapping and an error is returned.
191+
func CalculateFlatSchemaDiff(o, n FlatSchema) (map[string]FieldDiff, error) {
192+
diffMap := map[string]FieldDiff{}
193+
for field, schema := range o {
194+
if _, ok := n[field]; !ok {
195+
return diffMap, fmt.Errorf("field %q in existing not found in new", field)
196+
}
197+
newSchema := n[field]
198+
199+
// Copy the schemas and remove any child properties for comparison.
200+
// In theory this will focus in on detecting changes for only the
201+
// field we are looking at and ignore changes in the children fields.
202+
// Since we are iterating through the map that should have all fields
203+
// we should still detect changes in the children fields.
204+
oldCopy := schema.DeepCopy()
205+
newCopy := newSchema.DeepCopy()
206+
oldCopy.Properties = nil
207+
newCopy.Properties = nil
208+
if !reflect.DeepEqual(oldCopy, newCopy) {
209+
diffMap[field] = FieldDiff{
210+
Old: oldCopy,
211+
New: newCopy,
212+
}
213+
}
214+
}
215+
return diffMap, nil
216+
}

0 commit comments

Comments
 (0)