Skip to content

Commit

Permalink
Add support for CRD ConversionReview types (#999)
Browse files Browse the repository at this point in the history
* Add conversion webhook typings

Signed-off-by: Mikail Bagishov <bagishov.mikail@yandex.ru>

* Add simple test

Signed-off-by: Mikail Bagishov <bagishov.mikail@yandex.ru>

* Fix formatting

Signed-off-by: Mikail Bagishov <bagishov.mikail@yandex.ru>

* Improve docs

Signed-off-by: Mikail Bagishov <bagishov.mikail@yandex.ru>

* Add is_success & is_failure methods

Signed-off-by: Mikail Bagishov <bagishov.mikail@yandex.ru>

* Use our Status in admission

Signed-off-by: Mikail Bagishov <bagishov.mikail@yandex.ru>

* Expand test

Signed-off-by: Mikail Bagishov <bagishov.mikail@yandex.ru>

* Rename response methods

Signed-off-by: Mikail Bagishov <bagishov.mikail@yandex.ru>

* Avoid optionality

Signed-off-by: Mikail Bagishov <bagishov.mikail@yandex.ru>

* Move some code around

Signed-off-by: Mikail Bagishov <bagishov.mikail@yandex.ru>

* Fix copy-paste error

Signed-off-by: Mikail Bagishov <bagishov.mikail@yandex.ru>

* Improve response signatures

Signed-off-by: Mikail Bagishov <bagishov.mikail@yandex.ru>

* Change status in invalid admission response

Signed-off-by: Mikail Bagishov <bagishov.mikail@yandex.ru>

Signed-off-by: Mikail Bagishov <bagishov.mikail@yandex.ru>
Co-authored-by: Eirik A <sszynrae@gmail.com>
  • Loading branch information
MikailBag and clux committed Sep 16, 2022
1 parent 15faafb commit cc2a2b9
Show file tree
Hide file tree
Showing 7 changed files with 366 additions and 24 deletions.
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(), "InvalidRequest"),
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>,
/// 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,
/// 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(()),
}

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 {
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

0 comments on commit cc2a2b9

Please sign in to comment.