Fix status subresource ratcheting

This commit is contained in:
Joel Speed 2025-01-07 13:23:06 +00:00
parent f1b3fdf7e6
commit 091fa29390
No known key found for this signature in database
GPG Key ID: 6E80578D6751DEFB
3 changed files with 115 additions and 8 deletions

View File

@ -22,11 +22,17 @@ import (
"sigs.k8s.io/structured-merge-diff/v4/fieldpath" "sigs.k8s.io/structured-merge-diff/v4/fieldpath"
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel"
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model"
structurallisttype "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/listtype" structurallisttype "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/listtype"
"k8s.io/apiextensions-apiserver/pkg/apiserver/validation"
apiextensionsfeatures "k8s.io/apiextensions-apiserver/pkg/features"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/util/validation/field"
celconfig "k8s.io/apiserver/pkg/apis/cel" celconfig "k8s.io/apiserver/pkg/apis/cel"
"k8s.io/apiserver/pkg/cel/common"
utilfeature "k8s.io/apiserver/pkg/util/feature"
) )
type statusStrategy struct { type statusStrategy struct {
@ -94,8 +100,17 @@ func (a statusStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Obj
return field.ErrorList{field.Invalid(field.NewPath(""), old, fmt.Sprintf("has type %T. Must be a pointer to an Unstructured type", old))} return field.ErrorList{field.Invalid(field.NewPath(""), old, fmt.Sprintf("has type %T. Must be a pointer to an Unstructured type", old))}
} }
var options []validation.ValidationOption
var celOptions []cel.Option
var correlatedObject *common.CorrelatedObject
if utilfeature.DefaultFeatureGate.Enabled(apiextensionsfeatures.CRDValidationRatcheting) {
correlatedObject = common.NewCorrelatedObject(uNew.Object, uOld.Object, &model.Structural{Structural: a.structuralSchema})
options = append(options, validation.WithRatcheting(correlatedObject))
celOptions = append(celOptions, cel.WithRatcheting(correlatedObject))
}
var errs field.ErrorList var errs field.ErrorList
errs = append(errs, a.customResourceStrategy.validator.ValidateStatusUpdate(ctx, uNew, uOld, a.scale)...) errs = append(errs, a.customResourceStrategy.validator.ValidateStatusUpdate(ctx, uNew, uOld, a.scale, options...)...)
// ratcheting validation of x-kubernetes-list-type value map and set // ratcheting validation of x-kubernetes-list-type value map and set
if newErrs := structurallisttype.ValidateListSetsAndMaps(nil, a.structuralSchema, uNew.Object); len(newErrs) > 0 { if newErrs := structurallisttype.ValidateListSetsAndMaps(nil, a.structuralSchema, uNew.Object); len(newErrs) > 0 {
@ -109,10 +124,15 @@ func (a statusStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Obj
if has, err := hasBlockingErr(errs); has { if has, err := hasBlockingErr(errs); has {
errs = append(errs, err) errs = append(errs, err)
} else { } else {
err, _ := celValidator.Validate(ctx, nil, a.customResourceStrategy.structuralSchema, uNew.Object, uOld.Object, celconfig.RuntimeCELCostBudget) err, _ := celValidator.Validate(ctx, nil, a.customResourceStrategy.structuralSchema, uNew.Object, uOld.Object, celconfig.RuntimeCELCostBudget, celOptions...)
errs = append(errs, err...) errs = append(errs, err...)
} }
} }
// No-op if not attached to context
if utilfeature.DefaultFeatureGate.Enabled(apiextensionsfeatures.CRDValidationRatcheting) {
validation.Metrics.ObserveRatchetingTime(*correlatedObject.Duration)
}
return errs return errs
} }

View File

@ -96,7 +96,7 @@ func validateKubeFinalizerName(stringValue string, fldPath *field.Path) []string
return allWarnings return allWarnings
} }
func (a customResourceValidator) ValidateStatusUpdate(ctx context.Context, obj, old *unstructured.Unstructured, scale *apiextensions.CustomResourceSubresourceScale) field.ErrorList { func (a customResourceValidator) ValidateStatusUpdate(ctx context.Context, obj, old *unstructured.Unstructured, scale *apiextensions.CustomResourceSubresourceScale, options ...apiextensionsvalidation.ValidationOption) field.ErrorList {
if errs := a.ValidateTypeMeta(ctx, obj); len(errs) > 0 { if errs := a.ValidateTypeMeta(ctx, obj); len(errs) > 0 {
return errs return errs
} }
@ -105,7 +105,7 @@ func (a customResourceValidator) ValidateStatusUpdate(ctx context.Context, obj,
allErrs = append(allErrs, validation.ValidateObjectMetaAccessorUpdate(obj, old, field.NewPath("metadata"))...) allErrs = append(allErrs, validation.ValidateObjectMetaAccessorUpdate(obj, old, field.NewPath("metadata"))...)
if status, hasStatus := obj.UnstructuredContent()["status"]; hasStatus { if status, hasStatus := obj.UnstructuredContent()["status"]; hasStatus {
allErrs = append(allErrs, apiextensionsvalidation.ValidateCustomResourceUpdate(field.NewPath("status"), status, old.UnstructuredContent()["status"], a.statusSchemaValidator)...) allErrs = append(allErrs, apiextensionsvalidation.ValidateCustomResourceUpdate(field.NewPath("status"), status, old.UnstructuredContent()["status"], a.statusSchemaValidator, options...)...)
} }
allErrs = append(allErrs, a.ValidateScaleStatus(ctx, obj, scale)...) allErrs = append(allErrs, a.ValidateScaleStatus(ctx, obj, scale)...)

View File

@ -45,6 +45,7 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
utilerrors "k8s.io/apimachinery/pkg/util/errors"
"k8s.io/apimachinery/pkg/util/uuid" "k8s.io/apimachinery/pkg/util/uuid"
"k8s.io/apimachinery/pkg/util/wait" "k8s.io/apimachinery/pkg/util/wait"
utilyaml "k8s.io/apimachinery/pkg/util/yaml" utilyaml "k8s.io/apimachinery/pkg/util/yaml"
@ -81,6 +82,7 @@ type ratchetingTestContext struct {
*testing.T *testing.T
DynamicClient dynamic.Interface DynamicClient dynamic.Interface
APIExtensionsClient clientset.Interface APIExtensionsClient clientset.Interface
SkipStatus bool
} }
type ratchetingTestOperation interface { type ratchetingTestOperation interface {
@ -95,6 +97,12 @@ type expectError struct {
func (e expectError) Do(ctx *ratchetingTestContext) error { func (e expectError) Do(ctx *ratchetingTestContext) error {
err := e.op.Do(ctx) err := e.op.Do(ctx)
if err != nil { if err != nil {
// If there is an error, it should happen when updating the CR
// and also when updating the CR status subresource.
if agg, ok := err.(utilerrors.Aggregate); ok && len(agg.Errors()) != 2 {
return fmt.Errorf("expected 2 errors, got %v", len(agg.Errors()))
}
return nil return nil
} }
return errors.New("expected error") return errors.New("expected error")
@ -190,8 +198,51 @@ func (a applyPatchOperation) Do(ctx *ratchetingTestContext) error {
FieldManager: "manager", FieldManager: "manager",
}) })
return err if ctx.SkipStatus {
return err
}
errs := []error{}
if err != nil {
errs = append(errs, err)
}
statusPatch := &unstructured.Unstructured{
Object: map[string]interface{}{
"status": patch.Object,
},
}
statusPatch.SetKind(kind)
statusPatch.SetAPIVersion(a.gvr.GroupVersion().String())
statusPatch.SetName(a.name)
statusPatch.SetNamespace("default")
delete(patch.Object, "metadata")
delete(patch.Object, "apiVersion")
delete(patch.Object, "kind")
_, err = ctx.DynamicClient.
Resource(a.gvr).
Namespace(statusPatch.GetNamespace()).
ApplyStatus(
context.TODO(),
statusPatch.GetName(),
statusPatch,
metav1.ApplyOptions{
FieldManager: "manager",
})
if err != nil {
errs = append(errs, err)
}
if len(errs) > 0 {
return utilerrors.NewAggregate(errs)
}
return nil
} }
func (a applyPatchOperation) Description() string { func (a applyPatchOperation) Description() string {
@ -228,10 +279,21 @@ func (u updateMyCRDV1Beta1Schema) Do(ctx *ratchetingTestContext) error {
}}, }},
} }
if !ctx.SkipStatus {
// Duplicate the schema as a status schema
statusSchema := sch.DeepCopy()
sch.Properties["status"] = *statusSchema
}
for _, v := range myCRD.Spec.Versions { for _, v := range myCRD.Spec.Versions {
if v.Name != myCRDV1Beta1.Version { if v.Name != myCRDV1Beta1.Version {
continue continue
} }
*v.Subresources = apiextensionsv1.CustomResourceSubresources{
Status: &apiextensionsv1.CustomResourceSubresourceStatus{},
}
v.Schema.OpenAPIV3Schema = sch v.Schema.OpenAPIV3Schema = sch
} }
@ -288,6 +350,16 @@ func (p patchMyCRDV1Beta1Schema) Do(ctx *ratchetingTestContext) error {
return err return err
} }
statusPatch := map[string]interface{}{
"properties": map[string]interface{}{
"status": p.patch,
},
}
statusPatchJSON, err := json.Marshal(statusPatch)
if err != nil {
return err
}
myCRD, err := ctx.APIExtensionsClient.ApiextensionsV1().CustomResourceDefinitions().Get(context.TODO(), myCRDV1Beta1.Resource+"."+myCRDV1Beta1.Group, metav1.GetOptions{}) myCRD, err := ctx.APIExtensionsClient.ApiextensionsV1().CustomResourceDefinitions().Get(context.TODO(), myCRDV1Beta1.Resource+"."+myCRDV1Beta1.Group, metav1.GetOptions{})
if err != nil { if err != nil {
return err return err
@ -308,8 +380,13 @@ func (p patchMyCRDV1Beta1Schema) Do(ctx *ratchetingTestContext) error {
return err return err
} }
mergedStatus, err := jsonpatch.MergePatch(merged, statusPatchJSON)
if err != nil {
return err
}
var parsed apiextensionsv1.JSONSchemaProps var parsed apiextensionsv1.JSONSchemaProps
if err := json.Unmarshal(merged, &parsed); err != nil { if err := json.Unmarshal(mergedStatus, &parsed); err != nil {
return err return err
} }
@ -329,6 +406,7 @@ type ratchetingTestCase struct {
Name string Name string
Disabled bool Disabled bool
Operations []ratchetingTestOperation Operations []ratchetingTestOperation
SkipStatus bool
} }
func runTests(t *testing.T, cases []ratchetingTestCase) { func runTests(t *testing.T, cases []ratchetingTestCase) {
@ -372,9 +450,15 @@ func runTests(t *testing.T, cases []ratchetingTestCase) {
}, },
}, },
}, },
"status": {
Type: "object",
},
}, },
}, },
}, },
Subresources: &apiextensionsv1.CustomResourceSubresources{
Status: &apiextensionsv1.CustomResourceSubresourceStatus{},
},
}}, }},
Names: apiextensionsv1.CustomResourceDefinitionNames{ Names: apiextensionsv1.CustomResourceDefinitionNames{
Plural: resource, Plural: resource,
@ -399,6 +483,7 @@ func runTests(t *testing.T, cases []ratchetingTestCase) {
T: t, T: t,
DynamicClient: dynamicClient, DynamicClient: dynamicClient,
APIExtensionsClient: apiExtensionClient, APIExtensionsClient: apiExtensionClient,
SkipStatus: c.SkipStatus,
} }
for i, op := range c.Operations { for i, op := range c.Operations {
@ -1330,7 +1415,8 @@ func TestRatchetingFunctionality(t *testing.T) {
}, },
}, },
{ {
Name: "CEL Optional OldSelf", Name: "CEL Optional OldSelf",
SkipStatus: true, // oldSelf can never be null for a status update.
Operations: []ratchetingTestOperation{ Operations: []ratchetingTestOperation{
updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{ updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
Type: "object", Type: "object",
@ -1387,7 +1473,8 @@ func TestRatchetingFunctionality(t *testing.T) {
Name: "Not_should_not_ratchet", Name: "Not_should_not_ratchet",
}, },
{ {
Name: "CEL_transition_rules_should_not_ratchet", Name: "CEL_transition_rules_should_not_ratchet",
SkipStatus: true, // Adding a broken status transition rule prevents the object from being updated.
Operations: []ratchetingTestOperation{ Operations: []ratchetingTestOperation{
updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{ updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
Type: "object", Type: "object",