Skip to content
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

Initial KEP for API union/oneOf #926

Merged
merged 1 commit into from
Apr 30, 2019
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 272 additions & 0 deletions keps/sig-api-machinery/20190325-unions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
---
title: Union types
authors:
- "@apelisse"
owning-sig: sig-api-machinery
participating-sigs:
reviewers:
- @sttts
- @lavalamp
- @thockin
- @DirectXMan12
approvers:
- @lavalamp
editor: TBD
creation-date: 2019-03-25
last-updated: 2019-03-25
status: implementable
see-also:
- "/keps/sig-api-machinery/0006-apply.md"
replaces:
- https://docs.google.com/document/d/1lrV-P25ZTWukixE9ZWyvchfFR0NE2eCHlObiCUgNQGQ
superseded-by:
---

# Union types

## Summary

Modern data model definitions like OpenAPI v3 and protobuf (versions 2 and 3)
have a keyword to implement “oneof” or "union". They allow APIs to have a better
semantics, typically as a way to say “only one of the given fields can be
set”. We currently have multiple occurrences of this semantics in kubernetes core
types, at least:
- VolumeSource is a structure that holds the definition of all the possible
volume types, only one of them must be set, it doesn't have a discriminator.
- DeploymentStrategy is a structure that has a discrminator
apelisse marked this conversation as resolved.
Show resolved Hide resolved
"DeploymentStrategyType" which decides if "RollingUpate" should be set

The problem with the lack of solution is that:
- The API is implicit, and people don't know how to use it
- Clients can't know how to deal with that, especially if they can't parse the
OpenAPI
- Server can't understand the user intent and normalize the object properly

## Motivation

Currently, changing a value in an oneof type is difficult because the semantics
is implicit, which means that nothing can be built to automatically fix unions,
leading to many bugs and issues:
- https://github.com/kubernetes/kubernetes/issues/35345
- https://github.com/kubernetes/kubernetes/issues/24238
- https://github.com/kubernetes/kubernetes/issues/34292
- https://github.com/kubernetes/kubernetes/issues/6979
- https://github.com/kubernetes/kubernetes/issues/33766
- https://github.com/kubernetes/kubernetes/issues/24198
- https://github.com/kubernetes/kubernetes/issues/60340

And then, for other people:
- https://github.com/rancher/rancher/issues/13584
- https://github.com/helm/charts/pull/12319
- https://github.com/EnMasseProject/enmasse/pull/1974
- https://github.com/helm/charts/pull/11546
- https://github.com/kubernetes/kubernetes/pull/35343
apelisse marked this conversation as resolved.
Show resolved Hide resolved

This is replacing a lot of previous work and long-standing effort:
- Initially: https://github.com/kubernetes/community/issues/229, then
- https://github.com/kubernetes/community/pull/278
- https://github.com/kubernetes/community/pull/620
- https://github.com/kubernetes/kubernetes/pull/44597
- https://github.com/kubernetes/kubernetes/pull/50296
- https://github.com/kubernetes/kubernetes/pull/70436

Server-side [apply](http://features.k8s.io/555) is what enables this proposal to
become possible.

### Goals

The goal is to enable a union or "oneof" semantics in Kubernetes types, both for
in-tree types and for CRDs.

### Non-Goals

We're not planning to use this KEP to release the feature, but mostly as a way
to document what we're doing.

## Proposal
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have examples in our code of both "at most one of" and "exactly one of" blocks. Does that affect this proposal? Should we write strong rules in one case than the other?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is not really replacing the validation logic, just trying to help clear the fields so that there is at most one. If there is 0, I expect the validation will catch that. I'll think about it a little bit more, it'd be nice if we had a way to formalize that.

Copy link
Member Author

@apelisse apelisse Mar 29, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I thought about it, I like the parallel with protobuf: this gives a semantic for "at most one of" and clears other fields. It's up to validation to decide if exactly-one of is accepted (which can be implemented using open-api)


In order to support unions in a backward compatible way in kubernetes, we're
proposing the following changes.

Note that this proposes unions to be "at most one of". Whether exactly one is
supported or not should be implemented by the validation logic.

### Go tags

We're proposing a new type of tags for go types (in-tree types, and also
kubebuilder types):

- `// +union` before a structure means that the structure is a union. All the
fields must be optional (beside the discriminator) and will be inlucded as
members of the union. That structure CAN be embedded in another structure.
- `// +unionDeprecated` before a field means that it is part of the
thockin marked this conversation as resolved.
Show resolved Hide resolved
union. Multiple fields can have this prefix. These fields MUST BE optional,
omitempty and pointers. The field is named deprecated because we don't want
people to embed their unions directly in structures, and only exist because of
some existing core types (e.g. `Value` and `ValueFrom` in
[EnvVar](https://github.com/kubernetes/kubernetes/blob/3ebb8ddd8a21b/staging/src/k8s.io/api/core/v1/types.go#L1817-L1836)).
- `// +unionDiscriminator` before a field means that this field is the
discriminator for the union. Only one field per structure can have this
prefix. This field HAS TO be a string, and CAN be optional.

Multiple unions can exist per structure, but unions can't span across multiple
apelisse marked this conversation as resolved.
Show resolved Hide resolved
go structures (all the fields that are part of a union has to be together in the
same structure), examples of what is allowed:

```
// This will have one embedded union.
type TopLevelUnion struct {
Name string `json:"name"`

Union `json:",inline"`
}

// This will generate one union, with two fields and a discriminator.
// +union
type Union struct {
// +unionDiscriminator
// +optional
UnionType string `json:"unionType"`

// +optional
FieldA int `json:"fieldA"`
// +optional
FieldB int `json:"fieldB"`
}

// This also generates one union, with two fields and on discriminator.
type Union2 struct {
// +unionDiscriminator
Type string `json:"type"`
// +unionDeprecated
// +optional
Alpha int `json:"alpha"`
// +unionDeprecated
// +optional
Beta int `json:"beta"`
}

// This has 3 embedded unions:
// One for the fields that are directly embedded, one for Union, and one for Union2.
type InlinedUnion struct {
Name string `json:"name"`

// +unionDeprecated
// +optional
Field1 *int `json:"field1,omitempty"`
// +unionDeprecated
// +optional
Field2 *int `json:"field2,omitempty"`

Union `json:",inline"`
Union2 `json:",inline"`
}
```

### OpenAPI

OpenAPI v3 already allows a "oneOf" form, which is accepted by CRD validation
(and will continue to be accepted in the future). That oneOf form will be used
for validation, but is "on-top" of this proposal.

A new extension is created in the openapi to describe the behavior:
`x-kubernetes-unions`.

This is a list of unions that are part of this structure/object. Here is what
each list item is made of:
- `discriminator: <discriminator>` is set to the name of the discriminator
field, if present,
- `fields-to-discriminateBy: {"<fieldName>": "<discriminateName>"}` is a map of
fields that belong to the union to their discriminated names. The
discriminatedValue will typically be set to the name of the Go variable.

Conversion between OpenAPI v2 and OpenAPI v3 will preserve these fields.

### Discriminator

For backward compatibility reasons, discriminators should be added to existing
union structures as an optional string. This has a nice property that it's going
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this value is defaulted, it does not have to be optional anymore.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which remind me that client side validation does not see defaults yet and hence falls over a non-optional discriminator. I guess that's what you have in mind.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, if we make it required, then client-side validation will force people to set it, which we don't want.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On one hand, it should be OK to have clients be aware of defaulting rules (they are part of the API), so clients could make a copy, apply defaults, and validate that.

On the other hand, that fails in case of admission controllers. But if that is a requirement, then client-side validation can never work.

I know this is a little controversial, but I think it's OK to client-side default (in a temporary copy!!) to validate.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have dry-run, don't we?

Copy link
Member Author

@apelisse apelisse Apr 30, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have dry-run, don't we?

Sure

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also just stop validating required fields client-side.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see why this is important anyway, you can have a look at the behavior here (which I think is a big improvement): https://github.com/kubernetes-sigs/structured-merge-diff/blob/master/merge/union_test.go

to allow conflict detection when the selected union field is changed.

We also do strongly recommend new APIs to be written with a discriminator, and
tools (kube-builder) should probably enforce the presence of a discriminator in
CRDs.

The value of the discriminator is going to be set automatically by the apiserver
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you intend the value of the discriminator to exactly match the names of the discriminated fields. Please say that explicitly?

Is it an option to choose none of the fields, and if so, how is that represented in the discriminator?

when a new field is changed in the union. It will be set to the value of the
`fields-to-discriminateBy` for that specific field.

When the value of the discriminator is explicitly changed by the client, it
will be interpreted as an intention to clear all the other fields. See section
below.

### Normalizing on updates

A "normalization process" will run automatically for all creations and
modifications (either with update or patch). It will happen automatically in order
to clear fields and update discriminators. This process will run for both
core-types and CRDs. It will take place before validation. The sent object
doesn't have to be valid, but fields will be cleared in order to make it valid.
This process will also happen before fields tracking (server-side apply), so
changes in discriminator, even if implicit, will be owned by the client making
the update (and may result in conflicts).

This process works as follows:
- If there is a discriminator, and its value has changed, clear all fields but
the one specified by the discriminator,
- If there is no discriminator, or if its value hasn't changed,
- if there is exactly one field, set the discriminator when there is one
to that value. Otherwise,
- compare the fields set before and after. If there is exactly one field
added, set the discriminator (if present) to that value, and remove all
other fields. if more than one field has been added, leave the process so
that validation will fail.

#### "At most one" versus "exactly one"

The goal of this proposal is not to change the validation, but to help clients
to clear other fields in the union. Validation should be implemented for in-tree
types as it is today, or through "oneOf" properties in CRDs.

In other word, this is proposing to implement "at most one", and the exactly one
should be provided through another layer of validation (separated).

#### Clearing all the fields

Since the system is trying to do the right thing, it can be hard to "clear
everything". In that case, each API could decide to have their own "Nothing"
value in the discriminator, which will automatically trigger a clearing of all
fields beside "Nothing".
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An explicit empty string would not do?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're assuming that clients that deserialize in golang and are not aware of a newly introduced discriminator would reset that string by accident. We don't want to infer intent from that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the "Nothing" value is customizable, how will that happen via tags?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// +discriminator:nothingValue="Nothing" or some such, presumably. We've got at least one union in kube today that requires this.

It'd be nice to be able to explicitly say that you don't want this behavior though, for new APIs going forward.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have one of these and it works very well: DeploymentStrategy.

Recreate is the "nothing", RolloutStrategy is the one that has an associated value. We should let it up to validation to verify which values are accepted for the discriminator, but I don't think we should build anything more complicated here.


### Backward compatibilities properties

This normalization process has a few nice properties, especially for dumb
clients, when it comes to backward compatibility:
- A dumb client that doesn't know which fields belong to the union can just set
a new field and get all the others cleared automatically
- A dumb client that doesn't know about the discriminator is going to change a
field, leave the discriminator as it is, and should still expect the fields to
be cleared accordingly
- A dumb client that knows about the discriminator can change the discriminator
without knowing which fields to clear, they will get cleared automatically


### Validation

Objects have to be validated AFTER the normalization process, which is going to
leave multiple fields of the union if it can't normalize. As discussed in
drawbacks below, it can also be useful to validate apply requests before
applying them.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would normalization be part of the defaulter?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, for server-side apply it happens BEFORE we default, for all other modifying end-points, it happens after defaulting. It's part of the "field management"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, so the plumbing is mostly in place. good.


## Future Work

Since the proposal only normalizes the object after the patch has been applied,
it is hard to fail if the patch is invalid. There are scenarios where the patch
is invalid but it results in an unpredictable object. For example, if a patch
sends both a discriminator and a field that is not the discriminated field, it
will either clear the value sent if the discriminator changes, or it will change
the value of the sent discriminator.

Validating patches is not a problem that we want to tackle now, but we can
apelisse marked this conversation as resolved.
Show resolved Hide resolved
validate "Apply" objects to make sure that they do not define such broken
semantics.