-
-
Notifications
You must be signed in to change notification settings - Fork 326
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
Add support for CRD conversion #992
Conversation
Codecov Report
@@ Coverage Diff @@
## master #992 +/- ##
==========================================
- Coverage 72.60% 72.32% -0.29%
==========================================
Files 64 67 +3
Lines 4505 4661 +156
==========================================
+ Hits 3271 3371 +100
- Misses 1234 1290 +56
|
0016da8
to
315bb0f
Compare
Signed-off-by: Mikail Bagishov <[email protected]>
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 a lot for looking into this! Have left initial comments on the core parts because I think those parts are more important than the more opinionated converter, and it's important to get those right first.
/// Defines low-level typings. | ||
pub mod low_level; |
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.
the types in low_level are the core types it's expected for us to export in kube_core::conversion
imo, so everything in low_level
should be re-exported at the top level (and the low_level
module name should not be public)
/// Creates successful conversion response. | ||
/// `request_uid` must be equal to the `.uid` field in the request. | ||
/// `converted_objects` must specify objects in the exact same order as on input. | ||
pub fn success(request_uid: String, converted_objects: Vec<serde_json::Value>) -> Self { |
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 need the user to keep track of uid
, then it probably makes more sense to create the response from the request like we do for AdmissionResponse
, add mutate builders success/error (where success
takes the converted_objects
)
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 design worries me a bit - when user at first creates logically invalid (neither allow nor deny) response, and then makes a decision, user can forget to do second step in some branches. Do you think this risk is negligible enough?
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.
Well, it is real a point of concern, but the consequences are pretty benign as all of these will be rejected without an explicit success, so at worst you have a bug that will likely be caught early (also easy to check for in unit tests), and in all other cases you are probably just missing the rejection reason.
I think there may be some follow-up point to perhaps later tweak the design of this to go through a type-safe builder that requires the the key choices to be made at compile-time (like what rustls does for its ClientConfig
which we use in tls.rs), but I don't think this is critical enough to warrant that level of engineering. It's only one choice.
kube-core/src/conversion.rs
Outdated
for (idx, object) in req.objects.into_iter().enumerate() { | ||
match self.converter.convert(object, &req.desired_api_version) { | ||
Ok(c) => converted_objects.push(c), | ||
Err(error) => { | ||
let msg = format!("Conversion of object {} failed: {}", idx, error); | ||
return ConversionReview { | ||
types: review.types.clone(), | ||
request: None, | ||
response: Some(ConversionResponse::error(req.uid, msg)), | ||
}; | ||
} | ||
} | ||
} |
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 the meat of the handler. If we factor out the other bits (above and below), and make a helpers for conversion, then writing this fn is no longer that daunting.
Given the precedent in admission
, I think we should try to mimic that as much as possible, particularly since it maximises the amount of things the user can do with the structs (without having to reimplement everything).
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.
Given the precedent in admission
To be honest, I think that kube::core::admission
is too low-level and also needs some beautiful abstractions on top of it (like in kubebuilder, where you just need to implement on_create
, on_update
and on_delete
) :)
then writing this fn is no longer that daunting.
I agree that there is nothing complex in this function (especially after applying your suggestion about .into_review
). I also agree that sometimes user will write their own handler (e.g. when conversion function uses IO, so that async
-ness is required). However it seems to me that in most cases this function will suit user, so it makes some sense to have it in kube
.
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.
admission too low-level
We do actually want to stay as unopinionated as possible in the core part of kube here and represent the api, rather than make judgements on how it should be used. The runtime is really the only part that gets to make big judgements to more fit rust, otherwise deviations should be fairly small.
For admission controllers, which is so hairy to deal with in terms of cert setup anyway, I think it's probably a bigger win for the ecosystem if most users just picked or piggy-backed on something larger like kubewarden rather than us making two ways to do things and still forcing the full low-level cert/app pipeline on everyone.
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.
That said, if rust-centric, non-opinionated helper methods on those types can be made (as you said to perhaps match on various event types), in ways that does not detract from the original design, then I'm all for minor impls that help this. Admission should probably be a separate PR though. This is already large.
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.
The runtime is really the only part that gets to make big judgements to more fit rust
I'm all for moving some parts of this PR (e.g. StarConversion and/or ConversionHandler) to kube_runtime. I however propose that we do these movements right before merging, after review is done.
This is already large.
Yeah :). If this PR became in any way too big, I'm ready to split it into smaller ones (e.g. one for new structs, one for Conversion, one for StarConversion).
object: serde_json::Value, | ||
desired_api_version: &str, |
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 user just has a Value
here, how do we know what to do with that? We need to presumably cast that into our old version first, but then we need to know what version it is. An example on the trait would be nice (without invoking the handler).
&self, | ||
object: serde_json::Value, | ||
desired_api_version: &str, | ||
) -> Result<serde_json::Value, String>; |
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.
Can we have this trait be generic and return some dyn Resource + DeserializeOwned
rather than forcing everyone to go through Value
? I figure most users that are dilligent enough to do conversions will probably have proper structs for both versions 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.
- User will likely want to work with concrete type, so either way downcast is required.
- There is no way to go from
serde_json::Value
todyn ...
without requiring additional information from user (i.e. we can't guess whichDeserialize::deserialize
function to call based on just api version and kind). And I see no "nice" way to ask this information.
I think that we may add a ResourceRegistry
with the following API:
type ResourceRegistry;
/// Creates empty registry
fn new();
/// Adds resource to the registry.
fn register<K: Resource>(&mut self, dt: &K::DynamicType);
/// if apiVersion and kind are known, parses this obj and upcasts to Box<Any>, otherwise returns None
fn parse(&self, obj: serde_json::Value) -> Option<serde_json::Result<Box<dyn Any>>>
Or, alternatively, more complex and more type-safe API
type ResourceRegistry<U>; // when U = Box<dyn Any>, equivalent to the previous API
/// Creates empty registry
fn new();
/// Adds resource to the registry.
fn register<K: Resource + Into<U>>(&mut self, dt: &K::DynamicType);
/// if apiVersion and kind are known, parses this obj and upcasts to U, otherwise returns None
fn parse(&self, obj: serde_json::Value) -> Option<serde_json::Result<U>>
Now user can create such a registry and than use it inside impl Conversion
, but it is also useful in other contexts (e.g. writing admission webhook for multiple resources, or re-implementing kubectl describe
in rust).
If you think that such an abstraction suits kube-rs
, I can send a PR for 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.
- If we make the trait generic for the serialization type, won't this work?
pub trait Conversion<Source: DeserializeOwned, Destination: Serialize> {
fn convert(&self, obj: Source, desired_version: &str) -> Result<Destination>;
}
if Source
turns out to be DynamicObject
then that's fine (easy to convert from a Value
), and if users just convert them into their own K
then we do deserialization early and have this fn focus on the business logic - which it feels like it should be doing.
..probably would be hard to turn it into a generic TryFrom
but that would be ideal.
- I think that is probably overkill for kube for what it is; Deserialisation into a specific type. If there are many types the conversion hook deals with (which i think is a rare case), then it should be up to the app to multiplex.
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 current design, Conversion
represents not one possible conversion, but all of them (i.e. it does one of the N*(N-1)
things depending on input and desired versions, where N is total version count). Consequently Source = Destination
(because each version may appear as both input and output). And this is either DynamicObject
(or other highly dynamic value), or serde(untagged)
-enum of all supported versions.
So some downstream converter may look like
#[serde(untagged)]
enum FooVersions {
V1(FooV1),
V2(FooV2),
}
struct FooConversion;
impl Conversion<FooVersions> for FooConversion {
fn convert(&self, input: FooVersions, desired_version: &str) {
match (input, desired_version) {
(V1(v1), "v2") => /*convert v1 -> v2*/,
(V2(v2), "v1") => /*convert v2 -> v1*/,
_ => /*noop conversions v1 -> v1, or v2 -> v2*/
}
}
}
Better than parsing serde_json::Value
-es, but still seems like a boilerplate.
Alternatively, we may change Conversion
responsibility - from "thing which converts any version into any other" to "thing which converts from one particular version into another particular version". This will require putting more type-erasure magic into ConversionHandler
, but that's probably worth it.
This api usage might look something like:
impl Conversion<FooV1, Foov2> for FooConversion {
// strongly-typed!
fn convert(&self, foo: FooV1) -> Result<FooV2, String>;
}
impl Conversion<FooV2, FooV1> foo FooConversion {
// strongly-typed!
fn convert(&self, foo: FooV2) -> Result<FooV1>, String;
}
let handler = ConversionHandlerBuilder::new(FooConversion {})
.register::<FooV1, FooV2>()
.register::<FooV2, FooV1>()
.build();
What do you think?
probably would be hard to turn it into a generic TryFrom
Yep, I don't see a way to go from impl TryFrom<FooV1> for FooV2, impl TryFrom<FooV2> for V1
to FooVersions + string -> FooVersions
.
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.
Ah, I see. Yeah I think it would be better if the user just wrote plain conversions from type1 to type2 rather than a match (particularly if the normal case is a single in-flight version migration) and lift the version->type matching into the handler somehow.
In your expression:
impl TryFrom for FooV2, impl TryFrom for V1 to FooVersions + string -> FooVersions
What is the string there? The desired version?
I imagine if all the types we register impl Resource
we wouldn't even need to tell the handler what version it is, it can figure that out via api_version()
and then deserialize into the correct one at runtime.
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 the string there? The desired version?
Yes, from the Kubernetes PoV conversion webhook is fn(obj: json, desired: string) -> converted: json
.
lift the version->type matching into the handler somehow.
OK, I'm sure we can achieve this.
4b60d7d
to
9a37d08
Compare
Signed-off-by: Mikail Bagishov <[email protected]>
Signed-off-by: Mikail Bagishov <[email protected]>
Signed-off-by: Mikail Bagishov <[email protected]>
f9410f5
to
ff72a32
Compare
Signed-off-by: Mikail Bagishov <[email protected]>
Signed-off-by: Mikail Bagishov <[email protected]>
ae385c8
to
9f6331b
Compare
Signed-off-by: Mikail Bagishov <[email protected]>
Signed-off-by: Mikail Bagishov <[email protected]>
3d41e45
to
f453a31
Compare
Signed-off-by: Mikail Bagishov <[email protected]>
f453a31
to
4d40c1b
Compare
If you don't object, I will use that opportunity to split PR into several smaller ones. |
Ok, yeah, that is sensible. 👍 |
Motivation
Should close #865
Solution
This PR contains multiple API additions (I can split them into separate PRs if needed):
kube::core::conversion::low_level
- typings for the conversion-related k8s apis.kube::core::conversion::Converter
- simple utility which abstracts away annoying details (like the fact that kubernetes batches conversion requests)kube::core::conversion::StarConverter
- high-level utility which helps implement conversion in k8s-way (i.e. you define unversioned representation, converters to/from each version, and StarConverter implements the rest)kube::client::api::migrate_resources
- function which strips.status.storedVersions
of the CRD, migrating all objects to storage version.Additionally,
crd_derive_multi
is substantially modified: it now includes star-based conversion webhook and shows how this webhook achieves lossless conversion between versions.TODO: