-
Notifications
You must be signed in to change notification settings - Fork 1.5k
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
sig-api-machinery KEP: Defaulting for Custom Resources #1006
Conversation
3cfa64d
to
2fd2eef
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.
Thanks for writing this Stefan. I'd just really like native types and CRD to look and feel very similar. You're not mentioning how kubebuilder would integrate with this feature (I think that could be useful).
@apelisse added a sentence about kubebuilder. It just needs another tag to define the default. Should be super straight forward. /cc @DirectXMan12 |
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.
couple of comments inline, otherwise big 👍 from me
@DirectXMan12 addressed your comment. |
|
||
![Decoding steps which must apply defaults](20190426-crd-defaulting-pipeline.png) | ||
|
||
We rely on the validation steps in the request pipeline to verify that the default value is of the right type. |
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.
would it be practicable to validate the default value at CRD create/update time? If possible, I'd like to avoid persisting fundamentally flawed CRDs
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.
With structural schemas, yes. We can validate them in advance.
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.
More precisely we can verify the types, not value validation. So there will always be a chance that the default does not fullfil the later. But as both validation and defaults are under control of the same party, we should be fine.
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
|
||
We rely on the validation steps in the request pipeline to verify that the default value is of the right type. | ||
|
||
The `default` field in the CRD types is considered alpha quality. We will add a `CustomResourceDefaulting` feature gate. Values for `default` will be rejected if the gate is not enabled. |
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.
if we anticipate this field being able to be populated by default in 1.16, we must not fail validation if we encounter this in existing objects. we would need to follow a process similar to https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api_changes.md#alpha-field-in-existing-api-version
Since CRD validation has already been rejecting this field, the only tweak would be "Before persisting the object to storage, reject the disabled alpha field on create, and on update if the existing object does not already have a value in the field."
The important thing is that we allow/preserve data in the Default
field on update if the existing object had data in that field, even if the alpha feature gate is disabled
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.
sounds good
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 ratcheting validaton
type: array | ||
items: | ||
type: integer | ||
default: [1] |
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 went looking for the footnote...
...
it's been a long day
|
||
We do this in the serializer by passing a real defaulter to [`versioningserializer.NewCodec`](https://github.com/kubernetes/apimachinery/blob/master/pkg/runtime/serializer/versioning/versioning.go#L49) such that defaulting is done natively just after the binary payload has been unmarshalled into an `map[string]interface{}` and pruning of [KEP: Pruning for CustomResources](https://github.com/kubernetes/enhancements/pull/709) was done, compare the yellow boxes in the following figure: | ||
|
||
![Decoding steps which must apply defaults](20190426-crd-defaulting-pipeline.png) |
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 hard a hard time telling from the diagram, what about after reading the response from a conversion webhook?
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.
Like for native types: no defaulting after conversion.
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.
what is persisted into etcd in this scenario?
- crd has two versions (v1, v2)
- v1 defaults field
a
to1
- v2 defaults field
b
to2
- v2 is the storage version
- user submits v1 object with
a
andb
unset
Clearly, a
defaults to 1
as part of deserialize->default->validate of the user's request
What is less clear to me is if v2 defaulting (setting b
to 2
) is applied after conversion, before storing in etcd.
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.
oops, crossed wires. that answered my question, thanks.
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.
probably worth calling out explicitly (maybe even with that scenario). the v2 defaulting would get applied on the way out of storage, so from an API user's perspective, I think they would see the newly created object returned with both a:1, b:2
set, but only a:1
would be in etcd, right?
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.
made this explicit:
Like for native resources, we do defaulting
* during request payload deserialization
* after mutating webhook admission
* during read from storage.
Note: like for native resources, we do not default after webhook conversions. Hence, webhook conversions must be complete in the sense that they return defaulted objects. Technically we could do defaulting, but to match native resources, we do not.
2. recursively follow the given CustomResource instance and the structural schema, applying defaults where an object field is | ||
* undefined (`_, ok := obj[field]; !ok`) | ||
* `nil` if the field not nullable | ||
* empty in case of lists and maps, and if nullable is not set. |
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.
in case of lists
clarify how we determine this.
type: "array"
in the schema?_, ok := default.([]interface{})
?_, ok := value.([]interface{})
?
all of the above?
and maps
clarify how we determine this.
type: "object"
in the schema?_, ok := default.(map[string]interface{})
?_, ok := value.(map[string]interface{})
?
all of the above?
I mostly want to make sure defaulting doesn't replace an empty array or object with a default value of the correct type prior to validation and mask what should be reported to the user as a schema validation 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.
Made the cases explicit as you wrote down above.
Note: we could do type validation during pruning, i.e. as part of the deserialization process, to match native types.
|
||
[Kubebuilder's crd-gen](https://github.com/kubernetes-sigs/controller-tools/tree/master/cmd/crd) can make use of this feature by adding another tag, e.g. `// +default=<arbitrary-json-value>`. Defaults are arbitrary JSON values, which must also validate and are not subject to pruning (defaulting happens after pruning). This is an implicit assumption that will be checked by the apiserver. | ||
[Kubebuilder's crd-gen](https://github.com/kubernetes-sigs/controller-tools/tree/master/cmd/crd) can make use of this feature by adding another tag, e.g. `// +default=<arbitrary-json-value>`. Defaults are arbitrary JSON values, which must also validate (types are checked during CRD creation and update, value validation is checked for requests, but not for etcd reads) and are not subject to pruning (defaulting happens after pruning). |
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.
is it possible to ensure the default value does not contain a field that would get dropped via pruning? this would help prevent typos. I'm willing to do more expensive things in CRD create/update validation to improve the user experience, as long as it falls out in a relatively straightforward way in the code
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.
for a given JSONSchemaProps
object, I was envisioning checks like this:
if props.Default != null {
reflect.DeepEqual(
props.Default,
prune(
props.Default,
makeStructuralSchema(props),
isPreservingUnknownFields, /* from parent schemas or CRD field */
),
)
validate(props.Default, ConvertJSONSchemaProps(props))
}
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.
yeah, we can do that.
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
and for `array` type in the schema one of these: | ||
|
||
* `if v, ok := obj[fld]; !ok` => default | ||
* `else if !nullable && v == nil` => default |
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.
if I'm reading this correctly, we'd want a nullable field to skip all the checks after the first one, right? something like this?
* `if v, ok := obj[fld]; !ok` => default
* `else if nullable` => no default
* `else if v == nil` => default
...
same for object
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.
@liggitt I read this as being very similar to what Stefan wrote, but harder to read?
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.
if the schema specifies the field is nullable, and the current value is []
, my example would not default, and stefan's would
I think we want to avoid treating nil
and []
/{}
values differently for defaulting purposes
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.
Oh I see what happened here.
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.
If it's nil and non-nullable, shouldn't it be an error anyway?
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.
Let's think in terms of UX behavior:
{
defaulted-list: [] # Do I want this defaulted? Not sure, I'm specifying a value after all ...
}
---
{
defaulted-list: null # Do I want this defaulted? Not sure, I'm specificying a value after all ...
}
---
{
# defaulted-list should certainly be defaulted
}
One of the principle we used in apply was that if users specified something, then we assume that it's what they want, and setting something to nil is different from not setting it at all.
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.
If it's nil and non-nullable, shouldn't it be an error anyway?
That's a good point. We could default only if the property was completely absent.
Things I like about only defaulting unspecified properties:
- It's simpler to explain
- It's simpler to implement
- It doesn't mess with user data in any way
- It avoids masking schema errors if they explicitly send
null
for a property that is not nullable, even if it has a default
Opposing considerations:
- The distinction between "null" and "absent" is not easily maintained in all serialization formats (notably protobuf), so if that got lost in a round-trip, we could end up applying defaulting anyway (this isn't a huge concern, given we currently maintain the distinction for custom resources, so I think we have to figure out a way to continue doing so if we ever switch to persist in other formats like proto)
- We would not be able to replicate defaulting rules for built-in resources using this standard in a hypothetical world where built-in types get converted to CRDs (this also isn't a huge concern, given there are tons of defaulting rules for built-in types we couldn't replicate using openapi at all)
- Sending explicit
null
values in some patch formats today removes fields, so there's some prior art fornull == unset
in kube. For example,kubectl patch ... --type=merge -p '{"spec":{"key":null}}'
removes an existingkey
property, it does not persist a literalnull
value. I'm not sure what server-side apply does here.
Overall, I think the simpler and more intuitive behavior is probably what we want.
/lgtm would like a follow-up clarifying #1006 (comment) |
cd531c7
to
8c96d68
Compare
4ba62c6
to
5ce436c
Compare
/lgtm |
Spoken with the stakeholders, we think this is ready. /approve |
[APPROVALNOTIFIER] This PR is APPROVED This pull-request has been approved by: deads2k, liggitt, sttts 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 |
This topic is unblocked by #1002.