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

Add support for CRD ConversionReview types #999

Merged
merged 14 commits into from Sep 16, 2022
7 changes: 5 additions & 2 deletions kube-client/src/lib.rs
Expand Up @@ -137,7 +137,10 @@ mod test {
};
use futures::{StreamExt, TryStreamExt};
use k8s_openapi::api::core::v1::Pod;
use kube_core::params::{DeleteParams, Patch};
use kube_core::{
params::{DeleteParams, Patch},
response::StatusSummary,
};
use serde_json::json;
use tower::ServiceBuilder;

Expand Down Expand Up @@ -475,7 +478,7 @@ mod test {
let ep = EvictParams::default();
let eres = pods.evict("busybox-kube3", &ep).await?;
assert_eq!(eres.code, 201); // created
assert_eq!(eres.status, "Success");
assert!(eres.is_success());

Ok(())
}
Expand Down
13 changes: 4 additions & 9 deletions kube-core/src/admission.rs
Expand Up @@ -10,14 +10,12 @@ use crate::{
gvk::{GroupVersionKind, GroupVersionResource},
metadata::TypeMeta,
resource::Resource,
Status,
};

use std::collections::HashMap;

use k8s_openapi::{
api::authentication::v1::UserInfo,
apimachinery::pkg::{apis::meta::v1::Status, runtime::RawExtension},
};
use k8s_openapi::{api::authentication::v1::UserInfo, apimachinery::pkg::runtime::RawExtension};
use serde::{Deserialize, Serialize};
use thiserror::Error;

Expand Down Expand Up @@ -307,10 +305,7 @@ impl AdmissionResponse {
},
uid: Default::default(),
allowed: false,
result: Status {
reason: Some(reason.to_string()),
..Default::default()
},
result: Status::failure("", &reason.to_string()),
MikailBag marked this conversation as resolved.
Show resolved Hide resolved
patch: None,
patch_type: None,
audit_annotations: Default::default(),
Expand All @@ -322,7 +317,7 @@ impl AdmissionResponse {
#[must_use]
pub fn deny<T: ToString>(mut self, reason: T) -> Self {
self.allowed = false;
self.result.message = Some(reason.to_string());
self.result.message = reason.to_string();
self
}

Expand Down
8 changes: 8 additions & 0 deletions kube-core/src/conversion/mod.rs
@@ -0,0 +1,8 @@
//! Contains types useful for implementing custom resource conversion webhooks.

pub use self::types::{
ConversionRequest, ConversionResponse, ConversionReview, ConvertConversionReviewError,
};

/// Defines low-level typings.
mod types;
55 changes: 55 additions & 0 deletions kube-core/src/conversion/test_data/simple.json
@@ -0,0 +1,55 @@
{
"kind": "ConversionReview",
"apiVersion": "apiextensions.k8s.io/v1",
"request": {
"uid": "f263987e-4d58-465a-9195-bf72a1c83623",
"desiredAPIVersion": "nullable.se/v1",
"objects": [
{
"apiVersion": "nullable.se/v2",
"kind": "ConfigMapGenerator",
"metadata": {
"annotations": {
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"nullable.se/v2\",\"kind\":\"ConfigMapGenerator\",\"metadata\":{\"annotations\":{},\"name\":\"kek\",\"namespace\":\"default\"},\"spec\":{\"content\":\"x\"}}\n"
},
"creationTimestamp": "2022-09-04T14:21:34Z",
"generation": 1,
"managedFields": [
{
"apiVersion": "nullable.se/v2",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:metadata": {
"f:annotations": {
".": {},
"f:kubectl.kubernetes.io/last-applied-configuration": {}
}
},
"f:spec": {
".": {},
"f:content": {}
}
},
"manager": "kubectl-client-side-apply",
"operation": "Update",
"time": "2022-09-04T14:21:34Z"
}
],
"name": "kek",
"namespace": "default",
"uid": "af7e84e4-573e-4b6e-bb66-0ea578c740da"
},
"spec": {
"content": "x"
}
}
]
},
"response": {
"uid": "",
"convertedObjects": null,
"result": {
"metadata": {}
}
}
}
212 changes: 212 additions & 0 deletions kube-core/src/conversion/types.rs
@@ -0,0 +1,212 @@
use crate::{Status, TypeMeta};
use serde::{Deserialize, Deserializer, Serialize};
use thiserror::Error;

/// The `kind` field in [`TypeMeta`]
pub const META_KIND: &str = "ConversionReview";
/// The `api_version` field in [`TypeMeta`] on the v1 version
pub const META_API_VERSION_V1: &str = "apiextensions.k8s.io/v1";

#[derive(Debug, Error)]
#[error("request missing in ConversionReview")]
/// Returned when `ConversionReview` cannot be converted into `ConversionRequest`
pub struct ConvertConversionReviewError;

/// Struct that describes both request and response
#[derive(Serialize, Deserialize)]
pub struct ConversionReview {
/// Contains the API version and type of the request
#[serde(flatten)]
pub types: TypeMeta,
/// Contains conversion request
#[serde(skip_serializing_if = "Option::is_none")]
pub request: Option<ConversionRequest>,
/// Contains conversion response
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub response: Option<ConversionResponse>,
}

/// Part of ConversionReview which is set on input (i.e. generated by apiserver)
#[derive(Serialize, Deserialize)]
pub struct ConversionRequest {
/// [`TypeMeta`] of the [`ConversionReview`] this response was created from
///
/// This field dopied from the corresponding [`ConversionReview`].
/// It is not part of the Kubernetes API, it's consumed only by `kube`.
#[serde(skip)]
pub types: Option<TypeMeta>,
Copy link
Member

Choose a reason for hiding this comment

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

In admission we have this as a non-optional field for both Response and Request (with skip still), is there a reason to keep it optional?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No strong reason. It simplifies manual creation of ConversionRequest a little bit (i.e. it may be surprising for user to see non-optional field which is not set by apiserver).

/// Random uid uniquely identifying this conversion call
pub uid: String,
/// The API group and version the objects should be converted to
#[serde(rename = "desiredAPIVersion")]
pub desired_api_version: String,
/// The list of objects to convert
///
/// Note that list may contain one or more objects, in one or more versions.
// This field uses raw Value instead of Object/DynamicObject to simplify
// further downcasting.
pub objects: Vec<serde_json::Value>,
}

impl ConversionRequest {
/// Extracts request from the [`ConversionReview`]
pub fn from_review(review: ConversionReview) -> Result<Self, ConvertConversionReviewError> {
ConversionRequest::try_from(review)
}
}

impl TryFrom<ConversionReview> for ConversionRequest {
type Error = ConvertConversionReviewError;

fn try_from(review: ConversionReview) -> Result<Self, Self::Error> {
match review.request {
Some(mut req) => {
req.types = Some(review.types);
Ok(req)
}
None => Err(ConvertConversionReviewError),
}
}
}

/// Part of ConversionReview which is set on output (i.e. generated by conversion webhook)
#[derive(Serialize, Deserialize)]
pub struct ConversionResponse {
/// [`TypeMeta`] of the [`ConversionReview`] this response was derived from
///
/// This field is copied from the corresponding [`ConversionRequest`].
/// It is not part of the Kubernetes API, it's consumed only by `kube`.
#[serde(skip)]
pub types: Option<TypeMeta>,
/// Copy of .request.uid
pub uid: String,
/// Outcome of the conversion operation
///
/// Success: all objects were successfully converted
/// Failure: at least one object could not be converted.
/// It is recommended that conversion fails as rare as possible.
pub result: Status,
MikailBag marked this conversation as resolved.
Show resolved Hide resolved
/// Converted objects
///
/// This field should contain objects in the same order as in the request
/// Should be empty if conversion failed.
#[serde(rename = "convertedObjects")]
#[serde(deserialize_with = "parse_converted_objects")]
pub converted_objects: Vec<serde_json::Value>,
}

fn parse_converted_objects<'de, D>(de: D) -> Result<Vec<serde_json::Value>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Helper {
List(Vec<serde_json::Value>),
Null(()),
}
MikailBag marked this conversation as resolved.
Show resolved Hide resolved

let h: Helper = Helper::deserialize(de)?;
let res = match h {
Helper::List(l) => l,
Helper::Null(()) => Vec::new(),
};
Ok(res)
}

impl ConversionResponse {
/// Creates a new response, matching provided request
///
/// This response must be finalized with one of:
/// - [`ConversionResponse::success`] when conversion succeeded
/// - [`ConversionResponse::failure`] when conversion failed
pub fn for_request(request: ConversionRequest) -> Self {
ConversionResponse::from(request)
}

/// Creates successful conversion response
///
/// `converted_objects` must specify objects in the exact same order as on input.
pub fn success(mut self, converted_objects: Vec<serde_json::Value>) -> Self {
MikailBag marked this conversation as resolved.
Show resolved Hide resolved
self.result = Status::success();
self.converted_objects = converted_objects;
self
}

/// Creates failed conversion response (discouraged)
///
/// `request_uid` must be equal to the `.uid` field in the request.
/// `message` and `reason` will be returned to the apiserver.
pub fn failure(mut self, status: Status) -> Self {
self.result = status;
self
}

/// Creates failed conversion response, not matched with any request
///
/// You should only call this function when request couldn't be parsed into [`ConversionRequest`].
/// Otherwise use `error`.
pub fn invalid(status: Status) -> Self {
ConversionResponse {
types: None,
uid: String::new(),
result: status,
converted_objects: Vec::new(),
}
}

/// Converts response into a [`ConversionReview`] value, ready to be sent as a response
pub fn into_review(self) -> ConversionReview {
self.into()
}
}

impl From<ConversionRequest> for ConversionResponse {
fn from(request: ConversionRequest) -> Self {
ConversionResponse {
types: request.types,
uid: request.uid,
result: Status {
status: None,
code: 0,
message: String::new(),
reason: String::new(),
details: None,
},
converted_objects: Vec::new(),
}
}
}

impl From<ConversionResponse> for ConversionReview {
fn from(mut response: ConversionResponse) -> Self {
ConversionReview {
types: response.types.take().unwrap_or_else(|| {
// we don't know which uid, apiVersion and kind to use, let's just use something
TypeMeta {
api_version: META_API_VERSION_V1.to_string(),
kind: META_KIND.to_string(),
}
}),
request: None,
response: Some(response),
}
}
}

#[cfg(test)]
mod tests {
use super::{ConversionRequest, ConversionResponse};

#[test]
fn simple_request_parses() {
// this file contains dump of real request generated by kubernetes v1.22
let data = include_str!("./test_data/simple.json");
// check that we can parse this review, and all chain of conversion worls
let review = serde_json::from_str(data).unwrap();
let req = ConversionRequest::from_review(review).unwrap();
let res = ConversionResponse::for_request(req);
let _ = res.into_review();
}
}
2 changes: 2 additions & 0 deletions kube-core/src/lib.rs
Expand Up @@ -14,6 +14,8 @@
#[cfg(feature = "admission")]
pub mod admission;

pub mod conversion;

pub mod discovery;

pub mod dynamic;
Expand Down