diff --git a/kube-client/src/lib.rs b/kube-client/src/lib.rs index a256f1bb4..e79b7009e 100644 --- a/kube-client/src/lib.rs +++ b/kube-client/src/lib.rs @@ -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; @@ -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(()) } diff --git a/kube-core/src/admission.rs b/kube-core/src/admission.rs index 7c4b2cbc2..b72e27dba 100644 --- a/kube-core/src/admission.rs +++ b/kube-core/src/admission.rs @@ -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; @@ -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(), @@ -322,7 +317,7 @@ impl AdmissionResponse { #[must_use] pub fn deny(mut self, reason: T) -> Self { self.allowed = false; - self.result.message = Some(reason.to_string()); + self.result.message = reason.to_string(); self } diff --git a/kube-core/src/conversion/mod.rs b/kube-core/src/conversion/mod.rs new file mode 100644 index 000000000..ac0e07aa1 --- /dev/null +++ b/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; diff --git a/kube-core/src/conversion/test_data/simple.json b/kube-core/src/conversion/test_data/simple.json new file mode 100644 index 000000000..bc15d875b --- /dev/null +++ b/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": {} + } + } +} \ No newline at end of file diff --git a/kube-core/src/conversion/types.rs b/kube-core/src/conversion/types.rs new file mode 100644 index 000000000..b26fabb3f --- /dev/null +++ b/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, + /// Contains conversion response + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub response: Option, +} + +/// 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, + /// 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, +} + +impl ConversionRequest { + /// Extracts request from the [`ConversionReview`] + pub fn from_review(review: ConversionReview) -> Result { + ConversionRequest::try_from(review) + } +} + +impl TryFrom for ConversionRequest { + type Error = ConvertConversionReviewError; + + fn try_from(review: ConversionReview) -> Result { + 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, + /// 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, +} + +fn parse_converted_objects<'de, D>(de: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum Helper { + List(Vec), + 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) -> 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 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 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(); + } +} diff --git a/kube-core/src/lib.rs b/kube-core/src/lib.rs index f19474697..71b81b917 100644 --- a/kube-core/src/lib.rs +++ b/kube-core/src/lib.rs @@ -14,6 +14,8 @@ #[cfg(feature = "admission")] pub mod admission; +pub mod conversion; + pub mod discovery; pub mod dynamic; diff --git a/kube-core/src/response.rs b/kube-core/src/response.rs index cb55640b6..d6fd7b796 100644 --- a/kube-core/src/response.rs +++ b/kube-core/src/response.rs @@ -1,20 +1,18 @@ //! Generic api response types -use serde::Deserialize; +use serde::{Deserialize, Serialize}; /// A Kubernetes status object -/// -/// Equivalent to Status in k8s-openapi except we have have simplified options -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)] pub struct Status { - /// Suggested HTTP return code (0 if unset) - #[serde(default, skip_serializing_if = "num::Zero::is_zero")] - pub code: u16, - /// Status of the operation /// /// One of: `Success` or `Failure` - [more info](https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status) - #[serde(default, skip_serializing_if = "String::is_empty")] - pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + + /// Suggested HTTP return code (0 if unset) + #[serde(default, skip_serializing_if = "is_u16_zero")] + pub code: u16, /// A human-readable description of the status of this operation #[serde(default, skip_serializing_if = "String::is_empty")] @@ -35,8 +33,69 @@ pub struct Status { pub details: Option, } +impl Status { + /// Returns a successful `Status` + pub fn success() -> Self { + Status { + status: Some(StatusSummary::Success), + code: 0, + message: String::new(), + reason: String::new(), + details: None, + } + } + + /// Returns an unsuccessful `Status` + pub fn failure(message: &str, reason: &str) -> Self { + Status { + status: Some(StatusSummary::Failure), + code: 0, + message: message.to_string(), + reason: reason.to_string(), + details: None, + } + } + + /// Sets an explicit HTTP status code + pub fn with_code(mut self, code: u16) -> Self { + self.code = code; + self + } + + /// Adds details to the `Status` + pub fn with_details(mut self, details: StatusDetails) -> Self { + self.details = Some(details); + self + } + + /// Checks if this `Status` represents success + /// + /// Note that it is possible for `Status` to be in indeterminate state + /// when both `is_success` and `is_failure` return false. + pub fn is_success(&self) -> bool { + self.status == Some(StatusSummary::Success) + } + + /// Checks if this `Status` represents failure + /// + /// Note that it is possible for `Status` to be in indeterminate state + /// when both `is_success` and `is_failure` return false. + pub fn is_failure(&self) -> bool { + self.status == Some(StatusSummary::Failure) + } +} + +/// Overall status of the operation - whether it succeeded or not +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy)] +pub enum StatusSummary { + /// Operation succeeded + Success, + /// Operation failed + Failure, +} + /// Status details object on the [`Status`] object -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] #[serde(rename_all = "camelCase")] pub struct StatusDetails { /// The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described) @@ -69,12 +128,12 @@ pub struct StatusDetails { /// /// Some errors may indicate the client must take an alternate action - /// for those errors this field may indicate how long to wait before taking the alternate action. - #[serde(default, skip_serializing_if = "num::Zero::is_zero")] + #[serde(default, skip_serializing_if = "is_u32_zero")] pub retry_after_seconds: u32, } /// Status cause object on the [`StatusDetails`] object -#[derive(Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct StatusCause { /// A machine-readable description of the cause of the error. If this value is empty there is no information available. #[serde(default, skip_serializing_if = "String::is_empty")] @@ -92,6 +151,14 @@ pub struct StatusCause { pub field: String, } +fn is_u16_zero(&v: &u16) -> bool { + v == 0 +} + +fn is_u32_zero(&v: &u32) -> bool { + v == 0 +} + #[cfg(test)] mod test { use super::Status;