New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
KEP-4008: CRDValidationRatcheting: Add support for optional oldSelf
#121034
KEP-4008: CRDValidationRatcheting: Add support for optional oldSelf
#121034
Conversation
Skipping CI for Draft Pull Request. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd love to see a test.
staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/validation.go
Outdated
Show resolved
Hide resolved
a866a71
to
bddcf76
Compare
tests added |
/assign |
bddcf76
to
65d0f25
Compare
53e8924
to
26facf7
Compare
/retest |
26facf7
to
0ac3857
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Two minor comments, then the implementation LGTM. I glanced at the API changes as well and don't have any concerns, but will defer to API review for that.
DeclTypes: []*apiservercel.DeclType{ | ||
scopedType, | ||
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Curious if this was strictly needed since the decl is already added to the environment in the prior version options? If we don't need to declare it again maybe omit it? (if we do need to declare it again, I'll want to dig in to figure out why..)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was able to remove it. It needed to be added in an earlier implementation when I had called extend multiple times like the following pseudocode:
selfEnvSet := baseEnv.Extend({
// self definition
DeclTypes: [scopedType]
})
oldSelfEnvSet := selfEnvSet.Extend({
// oldself definition
})
optionalOldSelfEnvSet := selfEnvSet.Extend({
// optionalOldSelf definition
})
This caused field resolution errors that weren't resolved until we either added the DeclType to each call to Extend, or made it the form it is currently.
@jpbetz is it expected that the definition wouldn't be available in successive calls to Extend
?
// (e.g. self and oldSelf) to CEL values. One activation must be produced | ||
// for |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Last sentence is incomplete?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
whoops. added the rest:
// celActivationFactory produces a Activations, which resolve identifiers
// (e.g. self and oldSelf) to CEL values. One activation must be produced
// for each of the cases when oldSelf is optional and non-optional.
// - During a create, `oldSelf` evaluates to `null` | ||
// - During an update, `oldSelf` evaluates to `null` when the old object is |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just caught up on the latest code. The use of CEL Optionals here is really nice.
// `transition rule`. | ||
// | ||
// Transition rules by default are applied only on UPDATE requests and are | ||
// skipped if and old value could not be found. You can opt a transition |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// skipped if and old value could not be found. You can opt a transition | |
// skipped if an old value could not be found. You can opt a transition |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added
// When enabled, if the old object is missing the value or is being | ||
// created for the first time, then `oldSelf` will be a CEL optional. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
when enabled, isn't oldSelf
always a CEL optional?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rephrased:
// When enabled `oldSelf` will be a CEL optional whose value will be
// `None` if there is no old value, or when the object is initially created.
//
// You may check for presence of oldSelf using `oldSelf.hasValue()` and
// unwrap it after checking using `oldSelf.value()`. Check the CEL
// documentation for Optional types for more information:
// https://pkg.go.dev/github.com/google/cel-go/cel#OptionalTypes
//
@@ -210,6 +210,14 @@ type ValidationRule struct { | |||
// - 'map': `X + Y` performs a merge where the array positions of all keys in `X` are preserved but the values | |||
// are overwritten by values in `Y` when the key sets of `X` and `Y` intersect. Elements in `Y` with | |||
// non-intersecting keys are appended, retaining their partial order. | |||
// | |||
// If `rule` makes use of the `oldSelf` variable it is implicitly a | |||
// `transition rule`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By default, the `oldSelf` variable is the same type as `self`.
When `optionalOldSelf` is true, the `oldSelf` variable is a CEL
optional variable whose value() is the same type as `self`.
See the documentation for the `optionalOldSelf` field for details.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added
va := &validationActivation{ | ||
self: UnstructuredToVal(obj, sts), | ||
} | ||
optionalVA := &validationActivation{ | ||
self: va.self, | ||
hasOldSelf: true, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is worth a comment
hasOldSelf: true, | |
hasOldSelf: true, // this means the oldSelf variable is defined for CEL to reference, not that it has a value |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added
@@ -622,21 +632,44 @@ type validationActivation struct { | |||
hasOldSelf bool | |||
} | |||
|
|||
func validationActivationWithOldSelf(sts *schema.Structural, obj, oldObj interface{}) interpreter.Activation { | |||
func validationActivationWithOldSelf(sts *schema.Structural, obj, oldObj interface{}) (interpreter.Activation, interpreter.Activation) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
godoc or name the return values
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
added names to the return values
return va, optionalVA | ||
} | ||
|
||
func validationActivationWithOptionalOldSelf(sts *schema.Structural, obj, oldObj interface{}) interpreter.Activation { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this looks unused, drop it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removed
// transition rules are evaluated only if there is a comparable existing value | ||
continue | ||
} else if compiled.UsesOldSelf && allowsNilOldSelf { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it's confusing to make this an else block and make it rely on the feature gate... a rule that sets optionalOldSelf: true
should always use optionalOldSelfActivation
I'd suggest separating the calculation for which activation to use (based solely on the rule field) and whether to evaluate, based on both the feature gate and the rule:
optionalOldSelfRule := pointer.BoolDeref(rule.OptionalOldSelf, false)
if compiled.UsesOldSelf && oldObj == nil {
if !utilfeature.DefaultFeatureGate.Enabled(features.CRDValidationRatcheting) {
continue
}
if !optionalOldSelfRule {
continue
}
}
ruleActivation := activation
if optionalOldSelfRule {
ruleActivation = optionalOldSelfActivation
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
restructured as suggested
staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/compilation.go
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
copy doc updates to v1 as well
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should be there now
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
there should be type checking unit tests on the optional oldSelf to make sure we catch type stuff:
diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/compilation_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/compilation_test.go
index 18df5a42475..a774e818b9d 100644
--- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/compilation_test.go
+++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/compilation_test.go
@@ -26,13 +26,18 @@ import (
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
+ "k8s.io/utils/pointer"
+
apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model"
+ apiextensionsfeatures "k8s.io/apiextensions-apiserver/pkg/features"
"k8s.io/apimachinery/pkg/util/version"
celconfig "k8s.io/apiserver/pkg/apis/cel"
"k8s.io/apiserver/pkg/cel"
"k8s.io/apiserver/pkg/cel/environment"
+ utilfeature "k8s.io/apiserver/pkg/util/feature"
+ featuregatetesting "k8s.io/component-base/featuregate/testing"
)
const (
@@ -151,6 +156,8 @@ func (v transitionRuleMatcher) String() string {
}
func TestCelCompilation(t *testing.T) {
+ defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, apiextensionsfeatures.CRDValidationRatcheting, true)()
+
cases := []struct {
name string
input schema.Structural
@@ -625,6 +632,92 @@ func TestCelCompilation(t *testing.T) {
matchesAll(noError(), transitionRule(true)),
},
},
+ {
+ name: "optional primitive transition rule type checking",
+ input: schema.Structural{
+ Generic: schema.Generic{
+ Type: "integer",
+ },
+ Extensions: schema.Extensions{
+ XValidations: apiextensions.ValidationRules{
+ {Rule: "self >= oldSelf.value()", OptionalOldSelf: pointer.Bool(true)},
+ {Rule: "self >= oldSelf.orValue(1)", OptionalOldSelf: pointer.Bool(true)},
+ {Rule: "oldSelf.hasValue() ? self >= oldSelf.value() : true", OptionalOldSelf: pointer.Bool(true)},
+ {Rule: "self >= oldSelf", OptionalOldSelf: pointer.Bool(true)},
+ {Rule: "self >= oldSelf.orValue('')", OptionalOldSelf: pointer.Bool(true)},
+ },
+ },
+ },
+ expectedResults: []validationMatcher{
+ matchesAll(noError(), transitionRule(true)),
+ matchesAll(noError(), transitionRule(true)),
+ matchesAll(noError(), transitionRule(true)),
+ matchesAll(invalidError("optional")),
+ matchesAll(invalidError("orValue")),
+ },
+ },
+ {
+ name: "optional complex transition rule type checking",
+ input: schema.Structural{
+ Generic: schema.Generic{
+ Type: "object",
+ },
+ Properties: map[string]schema.Structural{
+ "i": schema.Structural{Generic: schema.Generic{Type: "integer"}},
+ "b": schema.Structural{Generic: schema.Generic{Type: "boolean"}},
+ "s": schema.Structural{Generic: schema.Generic{Type: "string"}},
+ "a": schema.Structural{
+ Generic: schema.Generic{Type: "array"},
+ Items: &schema.Structural{Generic: schema.Generic{Type: "integer"}},
+ },
+ "o": schema.Structural{
+ Generic: schema.Generic{Type: "object"},
+ Properties: map[string]schema.Structural{
+ "i": schema.Structural{Generic: schema.Generic{Type: "integer"}},
+ "b": schema.Structural{Generic: schema.Generic{Type: "boolean"}},
+ "s": schema.Structural{Generic: schema.Generic{Type: "string"}},
+ "a": schema.Structural{
+ Generic: schema.Generic{Type: "array"},
+ Items: &schema.Structural{Generic: schema.Generic{Type: "integer"}},
+ },
+ "o": schema.Structural{
+ Generic: schema.Generic{Type: "object"},
+ },
+ },
+ },
+ },
+ Extensions: schema.Extensions{
+ XValidations: apiextensions.ValidationRules{
+ {Rule: "self.i >= oldSelf.i.value()", OptionalOldSelf: pointer.Bool(true)},
+ {Rule: "self.s == oldSelf.s.value()", OptionalOldSelf: pointer.Bool(true)},
+ {Rule: "self.b == oldSelf.b.value()", OptionalOldSelf: pointer.Bool(true)},
+ {Rule: "self.o == oldSelf.o.value()", OptionalOldSelf: pointer.Bool(true)},
+ {Rule: "self.o.i >= oldSelf.o.i.value()", OptionalOldSelf: pointer.Bool(true)},
+ {Rule: "self.o.s == oldSelf.o.s.value()", OptionalOldSelf: pointer.Bool(true)},
+ {Rule: "self.o.b == oldSelf.o.b.value()", OptionalOldSelf: pointer.Bool(true)},
+ {Rule: "self.o.o == oldSelf.o.o.value()", OptionalOldSelf: pointer.Bool(true)},
+ {Rule: "self.o.i >= oldSelf.o.i.orValue(1)", OptionalOldSelf: pointer.Bool(true)},
+ {Rule: "oldSelf.hasValue() ? self.o.i >= oldSelf.o.i.value() : true", OptionalOldSelf: pointer.Bool(true)},
+ {Rule: "self.o.i >= oldSelf.o.i", OptionalOldSelf: pointer.Bool(true)},
+ {Rule: "self.o.i >= oldSelf.o.s.orValue(0)", OptionalOldSelf: pointer.Bool(true)},
+ },
+ },
+ },
+ expectedResults: []validationMatcher{
+ matchesAll(noError(), transitionRule(true)),
+ matchesAll(noError(), transitionRule(true)),
+ matchesAll(noError(), transitionRule(true)),
+ matchesAll(noError(), transitionRule(true)),
+ matchesAll(noError(), transitionRule(true)),
+ matchesAll(noError(), transitionRule(true)),
+ matchesAll(noError(), transitionRule(true)),
+ matchesAll(noError(), transitionRule(true)),
+ matchesAll(noError(), transitionRule(true)),
+ matchesAll(noError(), transitionRule(true)),
+ matchesAll(invalidError("optional")),
+ matchesAll(invalidError("orValue")),
+ },
+ },
{
name: "whitespace-only rule",
input: schema.Structural{
diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/validation_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/validation_test.go
index 236564f7d36..63119ebaa22 100644
--- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/validation_test.go
+++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/validation_test.go
@@ -3667,6 +3667,18 @@ func TestOptionalOldSelf(t *testing.T) {
"foo": stringType,
}), "self.foo == 'valid' || (oldSelf.hasValue() && oldSelf.value().foo != 'valid')"),
},
+ {
+ name: "allow invalid new value if old value is also invalid with chained optionals",
+ obj: map[string]interface{}{
+ "foo": "invalid again",
+ },
+ oldObj: map[string]interface{}{
+ "foo": "invalid",
+ },
+ schema: withRulePtr(objectTypePtr(map[string]schema.Structural{
+ "foo": stringType,
+ }), "self.foo == 'valid' || oldSelf.foo.orValue('') != 'valid'"),
+ },
{
name: "block invalid new value if old value is valid",
obj: map[string]interface{}{
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added
looks like some TestValidateCustomResourceDefinitionValidation unit test failures as well |
I know we validate a kubernetes/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting/validation.go Lines 76 to 126 in 263ab25
It looks like we're currently passing If the ratcheting validation feature is enabled, and it passed that first validation check, should we also validate with |
I think that'd be prudent. Ive added it and a test for it |
staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/compilation.go
Show resolved
Hide resolved
} | ||
|
||
return compResults, nil | ||
} | ||
|
||
func prepareEnvSet(baseEnvSet *environment.EnvSet, declType *apiservercel.DeclType) (*environment.EnvSet, error) { | ||
func prepareEnvSet(baseEnvSet *environment.EnvSet, declType *apiservercel.DeclType) (*environment.EnvSet, *environment.EnvSet, error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
doc or name the return vars
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
named return vars
staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/validation.go
Outdated
Show resolved
Hide resolved
staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/validation.go
Outdated
Show resolved
Hide resolved
@@ -96,6 +98,11 @@ func validate(ctx context.Context, pth *field.Path, s *structuralschema.Structur | |||
if remainingCost < 0 { | |||
return allErrs, nil, remainingCost | |||
} | |||
|
|||
if len(celErrs) == 0 && utilfeature.DefaultFeatureGate.Enabled(apiextensionsfeatures.CRDValidationRatcheting) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
needs a comment explaining why we're calling this again
also, I think we need to capture the cost of both types of runs and take whichever leaves less remaining cost
celErrs, rmCost := celValidator.Validate(ctx, pth.Child("default"), s, s.Default.Object, s.Default.Object, remainingCost)
allErrs = append(allErrs, celErrs...)
if len(celErrs) == 0 && utilfeature.DefaultFeatureGate.Enabled(apiextensionsfeatures.CRDValidationRatcheting) {
celErrs, rmCostWithoutOldObject := celValidator.Validate(ctx, pth.Child("default"), s, s.Default.Object, nil, remainingCost)
allErrs = append(allErrs, celErrs...)
if rmCostWithoutOldObject < rmCost {
rmCost = rmCostWithoutOldObject
}
}
remainingCost = rmCost
if remainingCost < 0 {
return allErrs, nil, remainingCost
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same comment applies below
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Applied feedback as suggested and added comments
0101780
to
77942a4
Compare
/lgtm |
LGTM label has been added. Git tree hash: 12b79c4256148f1e4495ff3df690d37574308a91
|
[APPROVALNOTIFIER] This PR is APPROVED This pull-request has been approved by: alexzielenski, jpbetz, liggitt The full list of commands accepted by this bot can be found here. The pull request process is described here
Needs approval from an approver in each of these files:
Approvers can indicate their approval by writing |
77942a4
to
42a70cd
Compare
Blind reapply
/lgtm
…On Fri, Nov 3, 2023 at 3:45 PM Kubernetes Prow Robot < ***@***.***> wrote:
New changes are detected. LGTM label has been removed.
—
Reply to this email directly, view it on GitHub
<#121034 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AAOXDLEIOC5A7Y5LHCJRWC3YCVXZXAVCNFSM6AAAAAA5WG6SX6VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMYTOOJTGIYDQNJQGQ>
.
You are receiving this because you were mentioned.Message ID:
***@***.***>
|
LGTM label has been added. Git tree hash: 5ea11e89f552ad0224f32363d568455aa988e3c5
|
What type of PR is this?
/kind feature
What this PR does / why we need it:
Which issue(s) this PR fixes:
NONE
Special notes for your reviewer:
Does this PR introduce a user-facing change?
Additional documentation e.g., KEPs (Kubernetes Enhancement Proposals), usage docs, etc.:
/cc @cici37