Skip to content

Commit 50a97fd

Browse files
authored
test: Add update test helpers (#1162)
Previously there was no way to test transitions that would be enforced via CEL. This commit fixes that and allows the testing of CEL transition rules on update, which will be necessary for some future handlers.
1 parent ebe71c7 commit 50a97fd

File tree

4 files changed

+173
-26
lines changed

4 files changed

+173
-26
lines changed

common/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ require (
1919
k8s.io/api v0.32.5
2020
k8s.io/apiextensions-apiserver v0.32.5
2121
k8s.io/apimachinery v0.32.5
22+
k8s.io/apiserver v0.32.5
2223
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738
2324
sigs.k8s.io/cluster-api v1.10.2
2425
sigs.k8s.io/cluster-api/test v1.10.2
@@ -85,7 +86,6 @@ require (
8586
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
8687
gopkg.in/inf.v0 v0.9.1 // indirect
8788
gopkg.in/yaml.v3 v3.0.1 // indirect
88-
k8s.io/apiserver v0.32.5 // indirect
8989
k8s.io/client-go v0.32.5 // indirect
9090
k8s.io/cluster-bootstrap v0.32.3 // indirect
9191
k8s.io/component-base v0.32.5 // indirect

common/pkg/testutils/capitest/variables.go

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
type VariableTestDef struct {
2323
Name string
2424
Vals any
25+
OldVals any
2526
ExpectError bool
2627
}
2728

@@ -65,14 +66,34 @@ func ValidateDiscoverVariables[T mutation.DiscoverVariables](
6566
encodedVals, err := json.Marshal(tt.Vals)
6667
g.Expect(err).NotTo(gomega.HaveOccurred())
6768

68-
validateErr := openapi.ValidateClusterVariable(
69-
&clusterv1.ClusterVariable{
70-
Name: variableName,
71-
Value: apiextensionsv1.JSON{Raw: encodedVals},
72-
},
73-
&variable,
74-
field.NewPath(variableName),
75-
).ToAggregate()
69+
var validateErr error
70+
71+
switch {
72+
case tt.OldVals != nil:
73+
encodedOldVals, err := json.Marshal(tt.OldVals)
74+
g.Expect(err).NotTo(gomega.HaveOccurred())
75+
validateErr = openapi.ValidateClusterVariableUpdate(
76+
&clusterv1.ClusterVariable{
77+
Name: variableName,
78+
Value: apiextensionsv1.JSON{Raw: encodedVals},
79+
},
80+
&clusterv1.ClusterVariable{
81+
Name: variableName,
82+
Value: apiextensionsv1.JSON{Raw: encodedOldVals},
83+
},
84+
&variable,
85+
field.NewPath(variableName),
86+
).ToAggregate()
87+
default:
88+
validateErr = openapi.ValidateClusterVariable(
89+
&clusterv1.ClusterVariable{
90+
Name: variableName,
91+
Value: apiextensionsv1.JSON{Raw: encodedVals},
92+
},
93+
&variable,
94+
field.NewPath(variableName),
95+
).ToAggregate()
96+
}
7697

7798
if tt.ExpectError {
7899
g.Expect(validateErr).To(gomega.HaveOccurred())

common/pkg/testutils/openapi/convert.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,5 +208,22 @@ func ConvertJSONSchemaPropsToAPIExtensions(
208208
}
209209
}
210210

211+
if schema.XValidations != nil {
212+
props.XValidations = make([]apiextensions.ValidationRule, 0, len(schema.XValidations))
213+
for _, v := range schema.XValidations {
214+
var reason *apiextensions.FieldValueErrorReason
215+
if v.Reason != "" {
216+
reason = ptr.To(apiextensions.FieldValueErrorReason(v.Reason))
217+
}
218+
props.XValidations = append(props.XValidations, apiextensions.ValidationRule{
219+
Rule: v.Rule,
220+
Message: v.Message,
221+
MessageExpression: v.MessageExpression,
222+
Reason: reason,
223+
FieldPath: v.FieldPath,
224+
})
225+
}
226+
}
227+
211228
return props, allErrs
212229
}

common/pkg/testutils/openapi/validate.go

Lines changed: 126 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,19 @@
44
package openapi
55

66
import (
7+
"context"
78
"encoding/json"
89
"fmt"
910
"strings"
1011

1112
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
1213
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
14+
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel"
1315
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting"
1416
structuralpruning "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning"
1517
"k8s.io/apiextensions-apiserver/pkg/apiserver/validation"
1618
"k8s.io/apimachinery/pkg/util/validation/field"
19+
celconfig "k8s.io/apiserver/pkg/apis/cel"
1720
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
1821
)
1922

@@ -26,62 +29,133 @@ func ValidateClusterVariable(
2629
definition *clusterv1.ClusterClassVariable,
2730
fldPath *field.Path,
2831
) field.ErrorList {
32+
validator, apiExtensionsSchema, structuralSchema, err := validatorAndSchemas(fldPath, definition)
33+
if err != nil {
34+
return field.ErrorList{err}
35+
}
36+
37+
variableValue, err := unmarshalAndDefaultVariableValue(fldPath, value, structuralSchema)
38+
if err != nil {
39+
return field.ErrorList{err}
40+
}
41+
42+
// Validate variable against the schema.
43+
// NOTE: We're reusing a library func used in CRD validation.
44+
if err := validation.ValidateCustomResource(fldPath, variableValue, validator); err != nil {
45+
return err
46+
}
47+
48+
// Validate variable against the schema using CEL.
49+
if err := validateCEL(fldPath, variableValue, nil, structuralSchema); err != nil {
50+
return err
51+
}
52+
53+
return validateUnknownFields(fldPath, value, variableValue, apiExtensionsSchema)
54+
}
55+
56+
func unmarshalAndDefaultVariableValue(
57+
fldPath *field.Path,
58+
value *clusterv1.ClusterVariable,
59+
s *structuralschema.Structural,
60+
) (any, *field.Error) {
2961
// Parse JSON value.
30-
var variableValue interface{}
62+
var variableValue any
3163
// Only try to unmarshal the clusterVariable if it is not nil, otherwise the variableValue is nil.
3264
// Note: A clusterVariable with a nil value is the result of setting the variable value to "null" via YAML.
3365
if value.Value.Raw != nil {
3466
if err := json.Unmarshal(value.Value.Raw, &variableValue); err != nil {
35-
return field.ErrorList{field.Invalid(fldPath.Child("value"), string(value.Value.Raw),
36-
fmt.Sprintf("variable %q could not be parsed: %v", value.Name, err))}
67+
return nil, field.Invalid(
68+
fldPath.Child("value"), string(value.Value.Raw),
69+
fmt.Sprintf("variable %q could not be parsed: %v", value.Name, err),
70+
)
3771
}
3872
}
3973

74+
defaulting.Default(variableValue, s)
75+
76+
return variableValue, nil
77+
}
78+
79+
func validatorAndSchemas(
80+
fldPath *field.Path, definition *clusterv1.ClusterClassVariable,
81+
) (validation.SchemaValidator, *apiextensions.JSONSchemaProps, *structuralschema.Structural, *field.Error) {
4082
// Convert schema to Kubernetes APIExtensions Schema.
4183
apiExtensionsSchema, allErrs := ConvertJSONSchemaPropsToAPIExtensions(
4284
&definition.Schema.OpenAPIV3Schema, field.NewPath("schema"),
4385
)
4486
if len(allErrs) > 0 {
45-
return field.ErrorList{field.InternalError(fldPath,
87+
return nil, nil, nil, field.InternalError(
88+
fldPath,
4689
fmt.Errorf(
4790
"failed to convert schema definition for variable %q; ClusterClass should be checked: %v",
4891
definition.Name,
4992
allErrs,
5093
),
51-
)}
94+
)
5295
}
5396

5497
// Create validator for schema.
5598
validator, _, err := validation.NewSchemaValidator(apiExtensionsSchema)
5699
if err != nil {
57-
return field.ErrorList{field.InternalError(fldPath,
100+
return nil, nil, nil, field.InternalError(
101+
fldPath,
58102
fmt.Errorf(
59103
"failed to create schema validator for variable %q; ClusterClass should be checked: %v",
60-
value.Name,
104+
definition.Name,
61105
err,
62106
),
63-
)}
107+
)
64108
}
65109

66110
s, err := structuralschema.NewStructural(apiExtensionsSchema)
67111
if err != nil {
68-
return field.ErrorList{field.InternalError(fldPath,
112+
return nil, nil, nil, field.InternalError(
113+
fldPath,
69114
fmt.Errorf(
70115
"failed to create structural schema for variable %q; ClusterClass should be checked: %v",
71-
value.Name,
116+
definition.Name,
72117
err,
73118
),
74-
)}
119+
)
75120
}
76-
defaulting.Default(variableValue, s)
77121

78-
// Validate variable against the schema.
79-
// NOTE: We're reusing a library func used in CRD validation.
80-
if err := validation.ValidateCustomResource(fldPath, variableValue, validator); err != nil {
81-
return err
122+
return validator, apiExtensionsSchema, s, nil
123+
}
124+
125+
func validateCEL(
126+
fldPath *field.Path,
127+
variableValue, oldVariableValue any,
128+
structuralSchema *structuralschema.Structural,
129+
) field.ErrorList {
130+
// Note: k/k CR validation also uses celconfig.PerCallLimit when creating the validator for a custom resource.
131+
// The current PerCallLimit gives roughly 0.1 second for each expression validation call.
132+
celValidator := cel.NewValidator(structuralSchema, false, celconfig.PerCallLimit)
133+
// celValidation will be nil if there are no CEL validations specified in the schema
134+
// under `x-kubernetes-validations`.
135+
if celValidator == nil {
136+
return nil
82137
}
83138

84-
return validateUnknownFields(fldPath, value, variableValue, apiExtensionsSchema)
139+
// Note: k/k CRD validation also uses celconfig.RuntimeCELCostBudget for the Validate call.
140+
// The current RuntimeCELCostBudget gives roughly 1 second for the validation of a variable value.
141+
if validationErrors, _ := celValidator.Validate(
142+
context.Background(),
143+
fldPath.Child("value"),
144+
structuralSchema,
145+
variableValue,
146+
oldVariableValue,
147+
celconfig.RuntimeCELCostBudget,
148+
); len(validationErrors) > 0 {
149+
var allErrs field.ErrorList
150+
for _, validationError := range validationErrors {
151+
// Set correct value in the field error. ValidateCustomResource sets the type instead of the value.
152+
validationError.BadValue = variableValue
153+
allErrs = append(allErrs, validationError)
154+
}
155+
return allErrs
156+
}
157+
158+
return nil
85159
}
86160

87161
// validateUnknownFields validates the given variableValue for unknown fields.
@@ -140,3 +214,38 @@ func validateUnknownFields(
140214

141215
return nil
142216
}
217+
218+
// ValidateClusterVariable validates an update to a clusterVariable.
219+
func ValidateClusterVariableUpdate(
220+
value, oldValue *clusterv1.ClusterVariable,
221+
definition *clusterv1.ClusterClassVariable,
222+
fldPath *field.Path,
223+
) field.ErrorList {
224+
validator, apiExtensionsSchema, structuralSchema, err := validatorAndSchemas(fldPath, definition)
225+
if err != nil {
226+
return field.ErrorList{err}
227+
}
228+
229+
variableValue, err := unmarshalAndDefaultVariableValue(fldPath, value, structuralSchema)
230+
if err != nil {
231+
return field.ErrorList{err}
232+
}
233+
234+
oldVariableValue, err := unmarshalAndDefaultVariableValue(fldPath, oldValue, structuralSchema)
235+
if err != nil {
236+
return field.ErrorList{err}
237+
}
238+
239+
// Validate variable against the schema.
240+
// NOTE: We're reusing a library func used in CRD validation.
241+
if err := validation.ValidateCustomResourceUpdate(fldPath, variableValue, oldVariableValue, validator); err != nil {
242+
return err
243+
}
244+
245+
// Validate variable against the schema using CEL.
246+
if err := validateCEL(fldPath, variableValue, oldVariableValue, structuralSchema); err != nil {
247+
return err
248+
}
249+
250+
return validateUnknownFields(fldPath, value, variableValue, apiExtensionsSchema)
251+
}

0 commit comments

Comments
 (0)