From 9265aa2d70ce22dd8268ab58ab7296463e08cbfe Mon Sep 17 00:00:00 2001 From: Guilherme Oenning Date: Wed, 11 Dec 2024 09:38:14 +0000 Subject: [PATCH 01/11] Add support for UTF-16 encoded kubeconfig files (#1654) * support for utf16 files Signed-off-by: goenning * add test cases for utf16 Signed-off-by: goenning --------- Signed-off-by: goenning --- kube-client/src/config/file_config.rs | 50 ++++++++++++++++-- .../config/test_data/kubeconfig_utf16be.yaml | Bin 0 -> 810 bytes .../config/test_data/kubeconfig_utf16le.yaml | Bin 0 -> 810 bytes .../src/config/test_data/kubeconfig_utf8.yaml | 19 +++++++ 4 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 kube-client/src/config/test_data/kubeconfig_utf16be.yaml create mode 100644 kube-client/src/config/test_data/kubeconfig_utf16le.yaml create mode 100644 kube-client/src/config/test_data/kubeconfig_utf8.yaml diff --git a/kube-client/src/config/file_config.rs b/kube-client/src/config/file_config.rs index dd41cc5db..c290289d5 100644 --- a/kube-client/src/config/file_config.rs +++ b/kube-client/src/config/file_config.rs @@ -1,6 +1,6 @@ use std::{ collections::HashMap, - fs, + fs, io, path::{Path, PathBuf}, }; @@ -351,8 +351,8 @@ const KUBECONFIG: &str = "KUBECONFIG"; impl Kubeconfig { /// Read a Config from an arbitrary location pub fn read_from>(path: P) -> Result { - let data = fs::read_to_string(&path) - .map_err(|source| KubeconfigError::ReadConfig(source, path.as_ref().into()))?; + let data = + read_path(&path).map_err(|source| KubeconfigError::ReadConfig(source, path.as_ref().into()))?; // Remap all files we read to absolute paths. let mut merged_docs = None; @@ -497,6 +497,33 @@ where }); } +fn read_path>(path: P) -> io::Result { + let bytes = fs::read(&path)?; + match bytes.as_slice() { + [0xFF, 0xFE, ..] => { + let utf16_data: Vec = bytes[2..] + .chunks(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + String::from_utf16(&utf16_data) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-16 LE")) + } + [0xFE, 0xFF, ..] => { + let utf16_data: Vec = bytes[2..] + .chunks(2) + .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]])) + .collect(); + String::from_utf16(&utf16_data) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-16 BE")) + } + [0xEF, 0xBB, 0xBF, ..] => String::from_utf8(bytes[3..].to_vec()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8 BOM")), + _ => { + String::from_utf8(bytes).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8")) + } + } +} + fn to_absolute(dir: &Path, file: &str) -> Option { let path = Path::new(&file); if path.is_relative() { @@ -969,4 +996,21 @@ users: json!({"audience": "foo", "other": "bar"}) ); } + + #[tokio::test] + async fn parse_kubeconfig_encodings() { + let files = vec![ + "kubeconfig_utf8.yaml", + "kubeconfig_utf16le.yaml", + "kubeconfig_utf16be.yaml", + ]; + + for file_name in files { + let path = PathBuf::from(format!("{}/src/config/test_data/{}", env!("CARGO_MANIFEST_DIR"), file_name)); + let cfg = Kubeconfig::read_from(path).unwrap(); + assert_eq!(cfg.clusters[0].name, "k3d-promstack"); + assert_eq!(cfg.contexts[0].name, "k3d-promstack"); + assert_eq!(cfg.auth_infos[0].name, "admin@k3d-k3s-default"); + } + } } diff --git a/kube-client/src/config/test_data/kubeconfig_utf16be.yaml b/kube-client/src/config/test_data/kubeconfig_utf16be.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1788dc1cdefae27922ad277a07c9e4e0e40ef35c GIT binary patch literal 810 zcma))&rX9t7{ur7Q}6=#CmLfonRxKv-TMmFHng+>+GvcguKs3)0BYK11G|~sZ+2$B z{rpnUMhiV^rdkcFwJK#ww9|!7lxwLic-!q~U@5y(+-TB`Jf3V}dgjaFcWuhNWi^Mt z25a*9?+dy|MWZL?8$FB|o5;9}e=D0)-J-go?u>D+)6tjdT33wAj-ld?6}7G` zK@0BPpkWPaUjdCJXY=UXT3FYw@0QgXAKzx_V0w|++hR|(hQcafv8W(*3Z i9opaLGUi>x{O@AV{Z3!->sR3f?8n@8<`fgX>*E{U?1$w5 literal 0 HcmV?d00001 diff --git a/kube-client/src/config/test_data/kubeconfig_utf16le.yaml b/kube-client/src/config/test_data/kubeconfig_utf16le.yaml new file mode 100644 index 0000000000000000000000000000000000000000..edcbc6530055aaf17a14697760b45ec1f7686495 GIT binary patch literal 810 zcma)4OK!q26r6QWkqe|y6;&v!N-VnQzI)f{n4R$}YfZ#*CW zoE)}T;saAu2wZJYB13{bp6~#V6?UxG&3?vO%H4il)ub8TPS!-4@$+!jn{wW9wIF`Q zdW{2da@%!wpTiqj1blKH^ChNey0ml%JoiD=+MjX!#LdiSU>{VyM zYR-GNe9(RmcJ95w?`c#RNxu;}>J&hvfhO literal 0 HcmV?d00001 diff --git a/kube-client/src/config/test_data/kubeconfig_utf8.yaml b/kube-client/src/config/test_data/kubeconfig_utf8.yaml new file mode 100644 index 000000000..20879b3a4 --- /dev/null +++ b/kube-client/src/config/test_data/kubeconfig_utf8.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +clusters: +- cluster: + certificate-authority-data: aGVsbG8K + server: https://0.0.0.0:6443 + name: k3d-promstack +contexts: +- context: + cluster: k3d-promstack + user: admin@k3d-promstack + name: k3d-promstack +users: +- name: admin@k3d-k3s-default + user: + client-certificate-data: aGVsbG8K + client-key-data: aGVsbG8K +current-context: k3d-promstack +kind: Config +preferences: {} \ No newline at end of file From 419442bd2c6069c4434c68d9e57ef8f639072609 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Dec 2024 09:55:16 +0000 Subject: [PATCH 02/11] rustfmt (#1661) Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: clux <134092+clux@users.noreply.github.com> --- kube-client/src/config/file_config.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/kube-client/src/config/file_config.rs b/kube-client/src/config/file_config.rs index c290289d5..af1ba688e 100644 --- a/kube-client/src/config/file_config.rs +++ b/kube-client/src/config/file_config.rs @@ -1006,7 +1006,11 @@ users: ]; for file_name in files { - let path = PathBuf::from(format!("{}/src/config/test_data/{}", env!("CARGO_MANIFEST_DIR"), file_name)); + let path = PathBuf::from(format!( + "{}/src/config/test_data/{}", + env!("CARGO_MANIFEST_DIR"), + file_name + )); let cfg = Kubeconfig::read_from(path).unwrap(); assert_eq!(cfg.clusters[0].name, "k3d-promstack"); assert_eq!(cfg.contexts[0].name, "k3d-promstack"); From 36882b60478ec94d94aeb3b02f9b501a55e90a61 Mon Sep 17 00:00:00 2001 From: pando85 Date: Thu, 12 Dec 2024 10:06:35 +0100 Subject: [PATCH 03/11] feat(runtime): Add series implementation for event recorder (#1655) Signed-off-by: Alexander Gil --- Cargo.toml | 7 +- kube-runtime/Cargo.toml | 1 + kube-runtime/src/events.rs | 387 ++++++++++++++++++++++++++++--------- 3 files changed, 305 insertions(+), 90 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 14fc4e283..f82c7afdf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,18 +48,19 @@ form_urlencoded = "1.2.0" futures = { version = "0.3.17", default-features = false } hashbrown = "0.15.0" home = "0.5.4" +hostname = "0.3" http = "1.1.0" http-body = "1.0.1" http-body-util = "0.1.2" hyper = "1.2.0" -hyper-util = "0.1.9" hyper-openssl = "0.10.2" hyper-rustls = { version = "0.27.1", default-features = false } hyper-socks2 = { version = "0.9.0", default-features = false } hyper-timeout = "0.5.1" +hyper-util = "0.1.9" json-patch = "3" -jsonptr = "0.6" jsonpath-rust = "0.7.3" +jsonptr = "0.6" k8s-openapi = { version = "0.23.0", default-features = false } openssl = "0.10.36" parking_lot = "0.12.0" @@ -74,8 +75,8 @@ schemars = "0.8.6" secrecy = "0.10.2" serde = "1.0.130" serde_json = "1.0.68" -serde-value = "0.7.0" serde_yaml = "0.9.19" +serde-value = "0.7.0" syn = "2.0.38" tame-oauth = "0.10.0" tempfile = "3.1.0" diff --git a/kube-runtime/Cargo.toml b/kube-runtime/Cargo.toml index a89492384..9006bd819 100644 --- a/kube-runtime/Cargo.toml +++ b/kube-runtime/Cargo.toml @@ -49,6 +49,7 @@ hashbrown.workspace = true k8s-openapi.workspace = true async-broadcast.workspace = true async-stream.workspace = true +hostname.workspace = true [dev-dependencies] kube = { path = "../kube", features = ["derive", "client", "runtime"], version = "<1.0.0, >=0.60.0" } diff --git a/kube-runtime/src/events.rs b/kube-runtime/src/events.rs index 5e0d9dd6f..5b811977b 100644 --- a/kube-runtime/src/events.rs +++ b/kube-runtime/src/events.rs @@ -1,13 +1,25 @@ //! Publishes events for objects for kubernetes >= 1.19 +use std::{ + collections::HashMap, + hash::{Hash, Hasher}, + sync::Arc, +}; + use k8s_openapi::{ - api::{core::v1::ObjectReference, events::v1::Event as K8sEvent}, + api::{ + core::v1::ObjectReference, + events::v1::{Event as K8sEvent, EventSeries}, + }, apimachinery::pkg::apis::meta::v1::{MicroTime, ObjectMeta}, - chrono::Utc, + chrono::{Duration, Utc}, }; use kube_client::{ - api::{Api, PostParams}, - Client, + api::{Api, Patch, PatchParams, PostParams}, + Client, ResourceExt, }; +use tokio::sync::RwLock; + +const CACHE_TTL: Duration = Duration::minutes(6); /// Minimal event type for publishing through [`Recorder::publish`]. /// @@ -64,6 +76,36 @@ pub enum EventType { Warning, } +/// [`ObjectReference`] with Hash and Eq implementations +/// +/// [`ObjectReference`]: k8s_openapi::api::core::v1::ObjectReference +#[derive(Clone, Debug, PartialEq)] +pub struct Reference(ObjectReference); + +impl Eq for Reference {} + +impl Hash for Reference { + fn hash(&self, state: &mut H) { + self.0.api_version.hash(state); + self.0.kind.hash(state); + self.0.name.hash(state); + self.0.namespace.hash(state); + self.0.uid.hash(state); + } +} + +/// Cache key for event deduplication +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct EventKey { + pub event_type: EventType, + pub action: String, + pub reason: String, + pub reporting_controller: String, + pub reporting_instance: Option, + pub regarding: Reference, + pub related: Option, +} + /// Information about the reporting controller. /// /// ``` @@ -99,7 +141,8 @@ pub struct Reporter { /// /// in the manifest of your controller. /// - /// NB: If no `instance` is provided, then `reporting_instance == reporting_controller` in the `Event`. + /// Note: If `instance` is not provided, the hostname is used. If the hostname is also + /// unavailable, `reporting_instance` defaults to `reporting_controller` in the `Event`. pub instance: Option, } @@ -115,9 +158,10 @@ impl From for Reporter { impl From<&str> for Reporter { fn from(es: &str) -> Self { + let instance = hostname::get().ok().and_then(|h| h.into_string().ok()); Self { controller: es.into(), - instance: None, + instance, } } } @@ -138,6 +182,8 @@ impl From<&str> for Reporter { /// instance: std::env::var("CONTROLLER_POD_NAME").ok(), /// }; /// +/// let recorder = Recorder::new(client, reporter); +/// /// // references can be made manually using `ObjectMeta` and `ApiResource`/`Resource` info /// let reference = ObjectReference { /// // [...] @@ -145,15 +191,17 @@ impl From<&str> for Reporter { /// }; /// // or for k8s-openapi / kube-derive types, use Resource::object_ref: /// // let reference = myobject.object_ref(); -/// -/// let recorder = Recorder::new(client, reporter, reference); -/// recorder.publish(Event { -/// action: "Scheduling".into(), -/// reason: "Pulling".into(), -/// note: Some("Pulling image `nginx`".into()), -/// type_: EventType::Normal, -/// secondary: None, -/// }).await?; +/// recorder +/// .publish( +/// &Event { +/// action: "Scheduling".into(), +/// reason: "Pulling".into(), +/// note: Some("Pulling image `nginx`".into()), +/// type_: EventType::Normal, +/// secondary: None, +/// }, +/// &reference, +/// ).await?; /// # Ok(()) /// # } /// ``` @@ -168,13 +216,13 @@ impl From<&str> for Reporter { /// ```yaml /// - apiGroups: ["events.k8s.io"] /// resources: ["events"] -/// verbs: ["create"] +/// verbs: ["create", "patch"] /// ``` #[derive(Clone)] pub struct Recorder { - events: Api, + client: Client, reporter: Reporter, - reference: ObjectReference, + cache: Arc>>, } impl Recorder { @@ -184,13 +232,66 @@ impl Recorder { /// /// Cluster scoped objects will publish events in the "default" namespace. #[must_use] - pub fn new(client: Client, reporter: Reporter, reference: ObjectReference) -> Self { - let default_namespace = "kube-system".to_owned(); // default does not work on k8s < 1.22 - let events = Api::namespaced(client, reference.namespace.as_ref().unwrap_or(&default_namespace)); + pub fn new(client: Client, reporter: Reporter) -> Self { + let cache = Arc::default(); Self { - events, + client, reporter, - reference, + cache, + } + } + + /// Builds unique event key based on reportingController, reportingInstance, regarding, reason + /// and note + fn get_event_key(&self, ev: &Event, regarding: &ObjectReference) -> EventKey { + EventKey { + event_type: ev.type_, + action: ev.action.clone(), + reason: ev.reason.clone(), + reporting_controller: self.reporter.controller.clone(), + reporting_instance: self.reporter.instance.clone(), + regarding: Reference(regarding.clone()), + related: ev.secondary.clone().map(Reference), + } + } + + // See https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.22/#event-v1-events-k8s-io + // for more detail on the fields + // and what's expected: https://kubernetes.io/docs/reference/using-api/deprecation-guide/#event-v125 + fn generate_event(&self, ev: &Event, reference: &ObjectReference) -> K8sEvent { + let now = Utc::now(); + K8sEvent { + action: Some(ev.action.clone()), + reason: Some(ev.reason.clone()), + deprecated_count: None, + deprecated_first_timestamp: None, + deprecated_last_timestamp: None, + deprecated_source: None, + event_time: Some(MicroTime(now)), + regarding: Some(reference.clone()), + note: ev.note.clone().map(Into::into), + metadata: ObjectMeta { + namespace: reference.namespace.clone(), + name: Some(format!( + "{}.{:x}", + reference.name.as_ref().unwrap_or(&self.reporter.controller), + now.timestamp_nanos_opt().unwrap_or_else(|| now.timestamp()) + )), + ..Default::default() + }, + reporting_controller: Some(self.reporter.controller.clone()), + reporting_instance: Some( + self.reporter + .instance + .clone() + .unwrap_or_else(|| self.reporter.controller.clone()), + ), + series: None, + type_: match ev.type_ { + EventType::Normal => Some("Normal".into()), + EventType::Warning => Some("Warning".into()), + }, + related: ev.secondary.clone(), } } @@ -198,62 +299,75 @@ impl Recorder { /// /// # Access control /// - /// The event object is created in the same namespace of the [`ObjectReference`] - /// you specified in [`Recorder::new`]. + /// The event object is created in the same namespace of the [`ObjectReference`]. /// Make sure that your controller has `create` permissions in the required namespaces /// for the `event` resource in the API group `events.k8s.io`. /// /// # Errors /// /// Returns an [`Error`](`kube_client::Error`) if the event is rejected by Kubernetes. - pub async fn publish(&self, ev: Event) -> Result<(), kube_client::Error> { - // See https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.22/#event-v1-events-k8s-io - // for more detail on the fields - // and what's expected: https://kubernetes.io/docs/reference/using-api/deprecation-guide/#event-v125 - self.events - .create(&PostParams::default(), &K8sEvent { - action: Some(ev.action), - reason: Some(ev.reason), - deprecated_count: None, - deprecated_first_timestamp: None, - deprecated_last_timestamp: None, - deprecated_source: None, - event_time: Some(MicroTime(Utc::now())), - regarding: Some(self.reference.clone()), - note: ev.note.map(Into::into), - metadata: ObjectMeta { - namespace: self.reference.namespace.clone(), - generate_name: Some(format!("{}-", self.reporter.controller)), - ..Default::default() - }, - reporting_controller: Some(self.reporter.controller.clone()), - reporting_instance: Some( - self.reporter - .instance - .clone() - .unwrap_or_else(|| self.reporter.controller.clone()), - ), - series: None, - type_: match ev.type_ { - EventType::Normal => Some("Normal".into()), - EventType::Warning => Some("Warning".into()), - }, - related: ev.secondary, - }) - .await?; + pub async fn publish(&self, ev: &Event, reference: &ObjectReference) -> Result<(), kube_client::Error> { + let now = Utc::now(); + + // gc past events older than now + CACHE_TTL + self.cache.write().await.retain(|_, v| { + if let Some(series) = v.series.as_ref() { + series.last_observed_time.0 + CACHE_TTL > now + } else if let Some(event_time) = v.event_time.as_ref() { + event_time.0 + CACHE_TTL > now + } else { + true + } + }); + + let key = self.get_event_key(ev, reference); + let event = match self.cache.read().await.get(&key) { + Some(e) => { + let count = if let Some(s) = &e.series { s.count + 1 } else { 2 }; + let series = EventSeries { + count, + last_observed_time: MicroTime(now), + }; + let mut event = e.clone(); + event.series = Some(series); + event + } + None => self.generate_event(ev, reference), + }; + + let events = Api::namespaced( + self.client.clone(), + reference.namespace.as_ref().unwrap_or(&"default".to_string()), + ); + if event.series.is_some() { + events + .patch(&event.name_any(), &PatchParams::default(), &Patch::Merge(&event)) + .await?; + } else { + events.create(&PostParams::default(), &event).await?; + }; + + { + let mut cache = self.cache.write().await; + cache.insert(key, event); + } Ok(()) } } #[cfg(test)] mod test { - use k8s_openapi::api::{ - core::v1::{Event as K8sEvent, Service}, - rbac::v1::ClusterRole, - }; - use kube_client::{Api, Client, Resource}; + use super::{Event, EventKey, EventType, Recorder, Reference, Reporter}; - use super::{Event, EventType, Recorder}; + use k8s_openapi::{ + api::{ + core::v1::{ComponentStatus, Service}, + events::v1::Event as K8sEvent, + }, + apimachinery::pkg::apis::meta::v1::MicroTime, + chrono::{Duration, Utc}, + }; + use kube::{Api, Client, Resource}; #[tokio::test] #[ignore = "needs cluster (creates an event for the default kubernetes service)"] @@ -262,15 +376,18 @@ mod test { let svcs: Api = Api::namespaced(client.clone(), "default"); let s = svcs.get("kubernetes").await?; // always a kubernetes service in default - let recorder = Recorder::new(client.clone(), "kube".into(), s.object_ref(&())); + let recorder = Recorder::new(client.clone(), "kube".into()); recorder - .publish(Event { - type_: EventType::Normal, - reason: "VeryCoolService".into(), - note: Some("Sending kubernetes to detention".into()), - action: "Test event - plz ignore".into(), - secondary: None, - }) + .publish( + &Event { + type_: EventType::Normal, + reason: "VeryCoolService".into(), + note: Some("Sending kubernetes to detention".into()), + action: "Test event - plz ignore".into(), + secondary: None, + }, + &s.object_ref(&()), + ) .await?; let events: Api = Api::namespaced(client, "default"); @@ -279,7 +396,27 @@ mod test { .into_iter() .find(|e| std::matches!(e.reason.as_deref(), Some("VeryCoolService"))) .unwrap(); - assert_eq!(found_event.message.unwrap(), "Sending kubernetes to detention"); + assert_eq!(found_event.note.unwrap(), "Sending kubernetes to detention"); + + recorder + .publish( + &Event { + type_: EventType::Normal, + reason: "VeryCoolService".into(), + note: Some("Sending kubernetes to detention twice".into()), + action: "Test event - plz ignore".into(), + secondary: None, + }, + &s.object_ref(&()), + ) + .await?; + + let event_list = events.list(&Default::default()).await?; + let found_event = event_list + .into_iter() + .find(|e| std::matches!(e.reason.as_deref(), Some("VeryCoolService"))) + .unwrap(); + assert!(found_event.series.is_some()); Ok(()) } @@ -289,19 +426,22 @@ mod test { async fn event_recorder_attaches_events_without_namespace() -> Result<(), Box> { let client = Client::try_default().await?; - let svcs: Api = Api::all(client.clone()); - let s = svcs.get("system:basic-user").await?; // always get this default ClusterRole - let recorder = Recorder::new(client.clone(), "kube".into(), s.object_ref(&())); + let component_status_api: Api = Api::all(client.clone()); + let s = component_status_api.get("scheduler").await?; + let recorder = Recorder::new(client.clone(), "kube".into()); recorder - .publish(Event { - type_: EventType::Normal, - reason: "VeryCoolServiceNoNamespace".into(), - note: Some("Sending kubernetes to detention without namespace".into()), - action: "Test event - plz ignore".into(), - secondary: None, - }) + .publish( + &Event { + type_: EventType::Normal, + reason: "VeryCoolServiceNoNamespace".into(), + note: Some("Sending kubernetes to detention without namespace".into()), + action: "Test event - plz ignore".into(), + secondary: None, + }, + &s.object_ref(&()), + ) .await?; - let events: Api = Api::namespaced(client, "kube-system"); + let events: Api = Api::namespaced(client, "default"); let event_list = events.list(&Default::default()).await?; let found_event = event_list @@ -309,10 +449,83 @@ mod test { .find(|e| std::matches!(e.reason.as_deref(), Some("VeryCoolServiceNoNamespace"))) .unwrap(); assert_eq!( - found_event.message.unwrap(), + found_event.note.unwrap(), "Sending kubernetes to detention without namespace" ); + recorder + .publish( + &Event { + type_: EventType::Normal, + reason: "VeryCoolServiceNoNamespace".into(), + note: Some("Sending kubernetes to detention without namespace twice".into()), + action: "Test event - plz ignore".into(), + secondary: None, + }, + &s.object_ref(&()), + ) + .await?; + + let event_list = events.list(&Default::default()).await?; + let found_event = event_list + .into_iter() + .find(|e| std::matches!(e.reason.as_deref(), Some("VeryCoolServiceNoNamespace"))) + .unwrap(); + assert!(found_event.series.is_some()); + Ok(()) + } + + #[tokio::test] + #[ignore = "needs cluster (creates an event for the default kubernetes service)"] + async fn event_recorder_cache_retain() -> Result<(), Box> { + let client = Client::try_default().await?; + + let svcs: Api = Api::namespaced(client.clone(), "default"); + let s = svcs.get("kubernetes").await?; // always a kubernetes service in default + + let reference = s.object_ref(&()); + let reporter: Reporter = "kube".into(); + let ev = Event { + type_: EventType::Normal, + reason: "TestCacheTtl".into(), + note: Some("Sending kubernetes to detention".into()), + action: "Test event - plz ignore".into(), + secondary: None, + }; + let key = EventKey { + event_type: ev.type_, + action: ev.action.clone(), + reason: ev.reason.clone(), + reporting_controller: reporter.controller.clone(), + regarding: Reference(reference.clone()), + reporting_instance: None, + related: None, + }; + + let reporter = Reporter { + controller: "kube".into(), + instance: None, + }; + let recorder = Recorder::new(client.clone(), reporter); + + recorder.publish(&ev, &s.object_ref(&())).await?; + let now = Utc::now(); + let past = now - Duration::minutes(10); + recorder.cache.write().await.entry(key).and_modify(|e| { + e.event_time = Some(MicroTime(past)); + }); + + recorder.publish(&ev, &s.object_ref(&())).await?; + + let events: Api = Api::namespaced(client, "default"); + let event_list = events.list(&Default::default()).await?; + let found_event = event_list + .into_iter() + .find(|e| std::matches!(e.reason.as_deref(), Some("TestCacheTtl"))) + .unwrap(); + assert_eq!(found_event.note.unwrap(), "Sending kubernetes to detention"); + assert!(found_event.series.is_none()); + Ok(()) } } From 0424cb48911b63c1e1572f2f382d9f3119c9f271 Mon Sep 17 00:00:00 2001 From: pando85 Date: Thu, 12 Dec 2024 14:40:02 +0100 Subject: [PATCH 04/11] chore(deps): Update Rust crate hostname to 0.4 (#1665) Signed-off-by: Alexander Gil --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index f82c7afdf..da72bb79c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ form_urlencoded = "1.2.0" futures = { version = "0.3.17", default-features = false } hashbrown = "0.15.0" home = "0.5.4" -hostname = "0.3" +hostname = "0.4" http = "1.1.0" http-body = "1.0.1" http-body-util = "0.1.2" From b104472d2672455771a2306f05614accbd700560 Mon Sep 17 00:00:00 2001 From: Danil Grigorev Date: Sun, 22 Dec 2024 04:04:39 +0100 Subject: [PATCH 05/11] Implement `derive(CELSchema)` macro for generating cel validation on CRDs (#1649) * Implement cel validation proc macro for generated CRDs - Extend with supported values from docs - https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#validation-rules - Implement as Validated derive macro - Use the raw Rule for the validated attribute Signed-off-by: Danil-Grigorev * Add cel_validate proc macro for completion, rename Signed-off-by: Danil-Grigorev * Add builder for the Rule Signed-off-by: Danil-Grigorev * Fmt fixes Signed-off-by: Danil-Grigorev * Implement as a JsonSchema generator via derive(ValidateSchema) Signed-off-by: Danil-Grigorev * Allow to pass rules to the CRD struct Signed-off-by: Danil-Grigorev * Add derive tests and doc support Signed-off-by: Danil-Grigorev * fmt fixes Signed-off-by: Danil-Grigorev * Rename to CELSchema, simplify derive addition in kube macro Signed-off-by: Danil-Grigorev * Move to a separate package Signed-off-by: Danil-Grigorev * clippy/fmt fixes Signed-off-by: Danil-Grigorev * Add doc comments to lib.rs Signed-off-by: Danil-Grigorev * Make attribute removal another fn Signed-off-by: Danil-Grigorev * Doc comment from suggestion Signed-off-by: Danil-Grigorev * Clippy nightly fixes Signed-off-by: Danil-Grigorev --------- Signed-off-by: Danil-Grigorev --- Cargo.toml | 1 + examples/crd_derive_schema.rs | 159 ++++++++++++--- kube-core/src/cel.rs | 282 +++++++++++++++++++++++++++ kube-core/src/lib.rs | 6 + kube-derive/Cargo.toml | 1 + kube-derive/src/cel_schema.rs | 235 ++++++++++++++++++++++ kube-derive/src/custom_resource.rs | 31 ++- kube-derive/src/lib.rs | 44 +++++ kube-derive/tests/crd_schema_test.rs | 19 +- kube/src/lib.rs | 4 + 10 files changed, 740 insertions(+), 42 deletions(-) create mode 100644 kube-core/src/cel.rs create mode 100644 kube-derive/src/cel_schema.rs diff --git a/Cargo.toml b/Cargo.toml index da72bb79c..ed419bfd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,3 +91,4 @@ tower-test = "0.4.0" tracing = "0.1.36" tracing-subscriber = "0.3.17" trybuild = "1.0.48" +prettyplease = "0.2.25" diff --git a/examples/crd_derive_schema.rs b/examples/crd_derive_schema.rs index 8c58afacb..70513f221 100644 --- a/examples/crd_derive_schema.rs +++ b/examples/crd_derive_schema.rs @@ -7,9 +7,8 @@ use kube::{ WatchEvent, WatchParams, }, runtime::wait::{await_condition, conditions}, - Client, CustomResource, CustomResourceExt, + CELSchema, Client, CustomResource, CustomResourceExt, }; -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; // This example shows how the generated schema affects defaulting and validation. @@ -19,15 +18,18 @@ use serde::{Deserialize, Serialize}; // - https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#defaulting // - https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#defaulting-and-nullable -#[derive(CustomResource, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone, JsonSchema)] +#[derive(CustomResource, CELSchema, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone)] #[kube( group = "clux.dev", version = "v1", kind = "Foo", namespaced, derive = "PartialEq", - derive = "Default" + derive = "Default", + rule = Rule::new("self.metadata.name != 'forbidden'"), )] +#[serde(rename_all = "camelCase")] +#[cel_validate(rule = Rule::new("self.nonNullable == oldSelf.nonNullable"))] pub struct FooSpec { // Non-nullable without default is required. // @@ -85,11 +87,27 @@ pub struct FooSpec { #[serde(default)] #[schemars(schema_with = "set_listable_schema")] set_listable: Vec, + // Field with CEL validation - #[serde(default)] - #[schemars(schema_with = "cel_validations")] + #[serde(default = "default_legal")] + #[cel_validate( + rule = Rule::new("self != 'illegal'").message(Message::Expression("'string cannot be illegal'".into())).reason(Reason::FieldValueForbidden), + rule = Rule::new("self != 'not legal'").reason(Reason::FieldValueInvalid), + )] cel_validated: Option, + + #[cel_validate(rule = Rule::new("self == oldSelf").message("is immutable"))] + foo_sub_spec: Option, +} + +#[derive(CELSchema, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone)] +pub struct FooSubSpec { + #[cel_validate(rule = "self != 'not legal'".into())] + field: String, + + other: Option, } + // https://kubernetes.io/docs/reference/using-api/server-side-apply/#merge-strategy fn set_listable_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { serde_json::from_value(serde_json::json!({ @@ -104,22 +122,14 @@ fn set_listable_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::sche .unwrap() } -// https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#validation-rules -fn cel_validations(_: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { - serde_json::from_value(serde_json::json!({ - "type": "string", - "x-kubernetes-validations": [{ - "rule": "self != 'illegal'", - "message": "string cannot be illegal" - }] - })) - .unwrap() -} - fn default_value() -> String { "default_value".into() } +fn default_legal() -> Option { + Some("legal".into()) +} + fn default_nullable() -> Option { Some("default_nullable".into()) } @@ -160,6 +170,7 @@ async fn main() -> Result<()> { default_listable: Default::default(), set_listable: Default::default(), cel_validated: Default::default(), + foo_sub_spec: Default::default(), }); // Set up dynamic resource to test using raw values. @@ -178,22 +189,23 @@ async fn main() -> Result<()> { // Test defaulting of `non_nullable_with_default` field let data = DynamicObject::new("baz", &api_resource).data(serde_json::json!({ "spec": { - "non_nullable": "a required field", + "nonNullable": "a required field", // `non_nullable_with_default` field is missing // listable values to patch later to verify merge strategies - "default_listable": vec![2], - "set_listable": vec![2], + "defaultListable": vec![2], + "setListable": vec![2], } })); let val = dynapi.create(&PostParams::default(), &data).await?.data; println!("{:?}", val["spec"]); // Defaulting happened for non-nullable field - assert_eq!(val["spec"]["non_nullable_with_default"], default_value()); + assert_eq!(val["spec"]["nonNullableWithDefault"], default_value()); // Listables - assert_eq!(serde_json::to_string(&val["spec"]["default_listable"])?, "[2]"); - assert_eq!(serde_json::to_string(&val["spec"]["set_listable"])?, "[2]"); + assert_eq!(serde_json::to_string(&val["spec"]["defaultListable"])?, "[2]"); + assert_eq!(serde_json::to_string(&val["spec"]["setListable"])?, "[2]"); + assert_eq!(serde_json::to_string(&val["spec"]["celValidated"])?, "\"legal\""); // Missing required field (non-nullable without default) is an error let data = DynamicObject::new("qux", &api_resource).data(serde_json::json!({ @@ -207,19 +219,24 @@ async fn main() -> Result<()> { assert_eq!(err.reason, "Invalid"); assert_eq!(err.status, "Failure"); assert!(err.message.contains("clux.dev \"qux\" is invalid")); - assert!(err.message.contains("spec.non_nullable: Required value")); + assert!(err.message.contains("spec.nonNullable: Required value")); } _ => panic!(), } + // Resource level metadata validations check + let forbidden = Foo::new("forbidden", FooSpec { ..FooSpec::default() }); + let res = foos.create(&PostParams::default(), &forbidden).await; + assert!(res.is_err()); + // Test the manually specified merge strategy let ssapply = PatchParams::apply("crd_derive_schema_example").force(); let patch = serde_json::json!({ "apiVersion": "clux.dev/v1", "kind": "Foo", "spec": { - "default_listable": vec![3], - "set_listable": vec![3] + "defaultListable": vec![3], + "setListable": vec![3] } }); let pres = foos.patch("baz", &ssapply, &Patch::Apply(patch)).await?; @@ -232,7 +249,7 @@ async fn main() -> Result<()> { "apiVersion": "clux.dev/v1", "kind": "Foo", "spec": { - "cel_validated": Some("illegal") + "celValidated": Some("illegal") } }); let cel_res = foos.patch("baz", &ssapply, &Patch::Apply(cel_patch)).await; @@ -243,17 +260,99 @@ async fn main() -> Result<()> { assert_eq!(err.reason, "Invalid"); assert_eq!(err.status, "Failure"); assert!(err.message.contains("Foo.clux.dev \"baz\" is invalid")); - assert!(err.message.contains("spec.cel_validated: Invalid value")); + assert!(err.message.contains("spec.celValidated: Forbidden")); assert!(err.message.contains("string cannot be illegal")); } _ => panic!(), } + + // cel validation triggers: + let cel_patch = serde_json::json!({ + "apiVersion": "clux.dev/v1", + "kind": "Foo", + "spec": { + "celValidated": Some("not legal") + } + }); + let cel_res = foos.patch("baz", &ssapply, &Patch::Apply(cel_patch)).await; + assert!(cel_res.is_err()); + match cel_res.err() { + Some(kube::Error::Api(err)) => { + assert_eq!(err.code, 422); + assert_eq!(err.reason, "Invalid"); + assert_eq!(err.status, "Failure"); + assert!(err.message.contains("Foo.clux.dev \"baz\" is invalid")); + assert!(err.message.contains("spec.celValidated: Invalid value")); + assert!(err.message.contains("failed rule: self != 'not legal'")); + } + _ => panic!(), + } + + let cel_patch = serde_json::json!({ + "apiVersion": "clux.dev/v1", + "kind": "Foo", + "spec": { + "fooSubSpec": { + "field": Some("not legal"), + } + } + }); + let cel_res = foos.patch("baz", &ssapply, &Patch::Apply(cel_patch)).await; + assert!(cel_res.is_err()); + match cel_res.err() { + Some(kube::Error::Api(err)) => { + assert_eq!(err.code, 422); + assert_eq!(err.reason, "Invalid"); + assert_eq!(err.status, "Failure"); + assert!(err.message.contains("Foo.clux.dev \"baz\" is invalid")); + assert!(err.message.contains("spec.fooSubSpec.field: Invalid value")); + assert!(err.message.contains("failed rule: self != 'not legal'")); + } + _ => panic!(), + } + + let cel_patch = serde_json::json!({ + "apiVersion": "clux.dev/v1", + "kind": "Foo", + "spec": { + "fooSubSpec": { + "field": Some("legal"), + } + } + }); + let cel_res = foos.patch("baz", &ssapply, &Patch::Apply(cel_patch)).await; + assert!(cel_res.is_ok()); + + let cel_patch = serde_json::json!({ + "apiVersion": "clux.dev/v1", + "kind": "Foo", + "spec": { + "fooSubSpec": { + "field": Some("legal"), + "other": "different", + } + } + }); + let cel_res = foos.patch("baz", &ssapply, &Patch::Apply(cel_patch)).await; + assert!(cel_res.is_err()); + match cel_res.err() { + Some(kube::Error::Api(err)) => { + assert_eq!(err.code, 422); + assert_eq!(err.reason, "Invalid"); + assert_eq!(err.status, "Failure"); + assert!(err.message.contains("Foo.clux.dev \"baz\" is invalid")); + assert!(err.message.contains("spec.fooSubSpec: Invalid value")); + assert!(err.message.contains("Invalid value: \"object\": is immutable")); + } + _ => panic!(), + } + // cel validation happy: let cel_patch_ok = serde_json::json!({ "apiVersion": "clux.dev/v1", "kind": "Foo", "spec": { - "cel_validated": Some("legal") + "celValidated": Some("legal") } }); foos.patch("baz", &ssapply, &Patch::Apply(cel_patch_ok)).await?; diff --git a/kube-core/src/cel.rs b/kube-core/src/cel.rs new file mode 100644 index 000000000..44318720d --- /dev/null +++ b/kube-core/src/cel.rs @@ -0,0 +1,282 @@ +//! CEL validation for CRDs + +use std::str::FromStr; + +#[cfg(feature = "schema")] use schemars::schema::Schema; +use serde::{Deserialize, Serialize}; + +/// Rule is a CEL validation rule for the CRD field +#[derive(Default, Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Rule { + /// rule represents the expression which will be evaluated by CEL. + /// The `self` variable in the CEL expression is bound to the scoped value. + pub rule: String, + /// message represents CEL validation message for the provided type + /// If unset, the message is "failed rule: {Rule}". + #[serde(flatten)] + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + /// fieldPath represents the field path returned when the validation fails. + /// It must be a relative JSON path, scoped to the location of the field in the schema + #[serde(skip_serializing_if = "Option::is_none")] + pub field_path: Option, + /// reason is a machine-readable value providing more detail about why a field failed the validation. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +impl Rule { + /// Initialize the rule + /// + /// ```rust + /// use kube_core::Rule; + /// let r = Rule::new("self == oldSelf"); + /// + /// assert_eq!(r.rule, "self == oldSelf".to_string()) + /// ``` + pub fn new(rule: impl Into) -> Self { + Self { + rule: rule.into(), + ..Default::default() + } + } + + /// Set the rule message. + /// + /// use kube_core::Rule; + /// ```rust + /// use kube_core::{Rule, Message}; + /// + /// let r = Rule::new("self == oldSelf").message("is immutable"); + /// assert_eq!(r.rule, "self == oldSelf".to_string()); + /// assert_eq!(r.message, Some(Message::Message("is immutable".to_string()))); + /// ``` + pub fn message(mut self, message: impl Into) -> Self { + self.message = Some(message.into()); + self + } + + /// Set the failure reason. + /// + /// use kube_core::Rule; + /// ```rust + /// use kube_core::{Rule, Reason}; + /// + /// let r = Rule::new("self == oldSelf").reason(Reason::default()); + /// assert_eq!(r.rule, "self == oldSelf".to_string()); + /// assert_eq!(r.reason, Some(Reason::FieldValueInvalid)); + /// ``` + pub fn reason(mut self, reason: impl Into) -> Self { + self.reason = Some(reason.into()); + self + } + + /// Set the failure field_path. + /// + /// use kube_core::Rule; + /// ```rust + /// use kube_core::Rule; + /// + /// let r = Rule::new("self == oldSelf").field_path("obj.field"); + /// assert_eq!(r.rule, "self == oldSelf".to_string()); + /// assert_eq!(r.field_path, Some("obj.field".to_string())); + /// ``` + pub fn field_path(mut self, field_path: impl Into) -> Self { + self.field_path = Some(field_path.into()); + self + } +} + +impl From<&str> for Rule { + fn from(value: &str) -> Self { + Self { + rule: value.into(), + ..Default::default() + } + } +} + +impl From<(&str, &str)> for Rule { + fn from((rule, msg): (&str, &str)) -> Self { + Self { + rule: rule.into(), + message: Some(msg.into()), + ..Default::default() + } + } +} +/// Message represents CEL validation message for the provided type +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Message { + /// Message represents the message displayed when validation fails. The message is required if the Rule contains + /// line breaks. The message must not contain line breaks. + /// Example: + /// "must be a URL with the host matching spec.host" + Message(String), + /// Expression declares a CEL expression that evaluates to the validation failure message that is returned when this rule fails. + /// Since messageExpression is used as a failure message, it must evaluate to a string. If messageExpression results in a runtime error, the runtime error is logged, and the validation failure message is produced + /// as if the messageExpression field were unset. If messageExpression evaluates to an empty string, a string with only spaces, or a string + /// that contains line breaks, then the validation failure message will also be produced as if the messageExpression field were unset, and + /// the fact that messageExpression produced an empty string/string with only spaces/string with line breaks will be logged. + /// messageExpression has access to all the same variables as the rule; the only difference is the return type. + /// Example: + /// "x must be less than max ("+string(self.max)+")" + #[serde(rename = "messageExpression")] + Expression(String), +} + +impl From<&str> for Message { + fn from(value: &str) -> Self { + Message::Message(value.to_string()) + } +} + +/// Reason is a machine-readable value providing more detail about why a field failed the validation. +/// +/// More in [docs](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#field-reason) +#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq)] +pub enum Reason { + /// FieldValueInvalid is used to report malformed values (e.g. failed regex + /// match, too long, out of bounds). + #[default] + FieldValueInvalid, + /// FieldValueForbidden is used to report valid (as per formatting rules) + /// values which would be accepted under some conditions, but which are not + /// permitted by the current conditions (such as security policy). + FieldValueForbidden, + /// FieldValueRequired is used to report required values that are not + /// provided (e.g. empty strings, null values, or empty arrays). + FieldValueRequired, + /// FieldValueDuplicate is used to report collisions of values that must be + /// unique (e.g. unique IDs). + FieldValueDuplicate, +} + +impl FromStr for Reason { + type Err = serde_json::Error; + + fn from_str(s: &str) -> Result { + serde_json::from_str(s) + } +} + +/// Validate takes schema and applies a set of validation rules to it. The rules are stored +/// on the top level under the "x-kubernetes-validations". +/// +/// ```rust +/// use schemars::schema::Schema; +/// use kube::core::{Rule, Reason, Message, validate}; +/// +/// let mut schema = Schema::Object(Default::default()); +/// let rules = &[Rule{ +/// rule: "self.spec.host == self.url.host".into(), +/// message: Some("must be a URL with the host matching spec.host".into()), +/// field_path: Some("spec.host".into()), +/// ..Default::default() +/// }]; +/// validate(&mut schema, rules)?; +/// assert_eq!( +/// serde_json::to_string(&schema).unwrap(), +/// r#"{"x-kubernetes-validations":[{"fieldPath":"spec.host","message":"must be a URL with the host matching spec.host","rule":"self.spec.host == self.url.host"}]}"#, +/// ); +/// # Ok::<(), serde_json::Error>(()) +///``` +#[cfg(feature = "schema")] +#[cfg_attr(docsrs, doc(cfg(feature = "schema")))] +pub fn validate(s: &mut Schema, rules: &[Rule]) -> Result<(), serde_json::Error> { + match s { + Schema::Bool(_) => (), + Schema::Object(schema_object) => { + schema_object + .extensions + .insert("x-kubernetes-validations".into(), serde_json::to_value(rules)?); + } + }; + Ok(()) +} + +/// Validate property mutates property under property_index of the schema +/// with the provided set of validation rules. +/// +/// ```rust +/// use schemars::JsonSchema; +/// use kube::core::{Rule, validate_property}; +/// +/// #[derive(JsonSchema)] +/// struct MyStruct { +/// field: Option, +/// } +/// +/// let gen = &mut schemars::gen::SchemaSettings::openapi3().into_generator(); +/// let mut schema = MyStruct::json_schema(gen); +/// let rules = &[Rule::new("self != oldSelf")]; +/// validate_property(&mut schema, 0, rules)?; +/// assert_eq!( +/// serde_json::to_string(&schema).unwrap(), +/// r#"{"type":"object","properties":{"field":{"type":"string","nullable":true,"x-kubernetes-validations":[{"rule":"self != oldSelf"}]}}}"# +/// ); +/// # Ok::<(), serde_json::Error>(()) +///``` +#[cfg(feature = "schema")] +#[cfg_attr(docsrs, doc(cfg(feature = "schema")))] +pub fn validate_property( + s: &mut Schema, + property_index: usize, + rules: &[Rule], +) -> Result<(), serde_json::Error> { + match s { + Schema::Bool(_) => (), + Schema::Object(schema_object) => { + let obj = schema_object.object(); + for (n, (_, schema)) in obj.properties.iter_mut().enumerate() { + if n == property_index { + return validate(schema, rules); + } + } + } + }; + + Ok(()) +} + +/// Merge schema properties in order to pass overrides or extension properties from the other schema. +/// +/// ```rust +/// use schemars::JsonSchema; +/// use kube::core::{Rule, merge_properties}; +/// +/// #[derive(JsonSchema)] +/// struct MyStruct { +/// a: Option, +/// } +/// +/// #[derive(JsonSchema)] +/// struct MySecondStruct { +/// a: bool, +/// b: Option, +/// } +/// let gen = &mut schemars::gen::SchemaSettings::openapi3().into_generator(); +/// let mut first = MyStruct::json_schema(gen); +/// let mut second = MySecondStruct::json_schema(gen); +/// merge_properties(&mut first, &mut second); +/// +/// assert_eq!( +/// serde_json::to_string(&first).unwrap(), +/// r#"{"type":"object","properties":{"a":{"type":"boolean"},"b":{"type":"boolean","nullable":true}}}"# +/// ); +/// # Ok::<(), serde_json::Error>(()) +#[cfg(feature = "schema")] +#[cfg_attr(docsrs, doc(cfg(feature = "schema")))] +pub fn merge_properties(s: &mut Schema, merge: &mut Schema) { + match s { + schemars::schema::Schema::Bool(_) => (), + schemars::schema::Schema::Object(schema_object) => { + let obj = schema_object.object(); + for (k, v) in &merge.clone().into_object().object().properties { + obj.properties.insert(k.clone(), v.clone()); + } + } + } +} diff --git a/kube-core/src/lib.rs b/kube-core/src/lib.rs index 969d10e0a..6ba9f81b6 100644 --- a/kube-core/src/lib.rs +++ b/kube-core/src/lib.rs @@ -25,6 +25,12 @@ pub use dynamic::{ApiResource, DynamicObject}; pub mod crd; pub use crd::CustomResourceExt; +pub mod cel; +pub use cel::{Message, Reason, Rule}; + +#[cfg(feature = "schema")] +pub use cel::{merge_properties, validate, validate_property}; + pub mod gvk; pub use gvk::{GroupVersion, GroupVersionKind, GroupVersionResource}; diff --git a/kube-derive/Cargo.toml b/kube-derive/Cargo.toml index 0b89ea2f0..01320eca3 100644 --- a/kube-derive/Cargo.toml +++ b/kube-derive/Cargo.toml @@ -34,3 +34,4 @@ chrono.workspace = true trybuild.workspace = true assert-json-diff.workspace = true runtime-macros = { git = "https://github.com/tyrone-wu/runtime-macros.git", rev = "e31f4de52e078d41aba4792a7ea30139606c1362" } +prettyplease.workspace = true diff --git a/kube-derive/src/cel_schema.rs b/kube-derive/src/cel_schema.rs new file mode 100644 index 000000000..9333e3508 --- /dev/null +++ b/kube-derive/src/cel_schema.rs @@ -0,0 +1,235 @@ +use darling::{FromDeriveInput, FromField, FromMeta}; +use proc_macro2::TokenStream; +use syn::{parse_quote, Attribute, DeriveInput, Expr, Ident, Path}; + +#[derive(FromField)] +#[darling(attributes(cel_validate))] +struct Rule { + #[darling(multiple, rename = "rule")] + rules: Vec, +} + +#[derive(FromDeriveInput)] +#[darling(attributes(cel_validate), supports(struct_named))] +struct CELSchema { + #[darling(default)] + crates: Crates, + ident: Ident, + #[darling(multiple, rename = "rule")] + rules: Vec, +} + +#[derive(Debug, FromMeta)] +struct Crates { + #[darling(default = "Self::default_kube_core")] + kube_core: Path, + #[darling(default = "Self::default_schemars")] + schemars: Path, + #[darling(default = "Self::default_serde")] + serde: Path, +} + +// Default is required when the subattribute isn't mentioned at all +// Delegate to darling rather than deriving, so that we can piggyback off the `#[darling(default)]` clauses +impl Default for Crates { + fn default() -> Self { + Self::from_list(&[]).unwrap() + } +} + +impl Crates { + fn default_kube_core() -> Path { + parse_quote! { ::kube::core } // by default must work well with people using facade crate + } + + fn default_schemars() -> Path { + parse_quote! { ::schemars } + } + + fn default_serde() -> Path { + parse_quote! { ::serde } + } +} + +pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { + let mut ast: DeriveInput = match syn::parse2(input) { + Err(err) => return err.to_compile_error(), + Ok(di) => di, + }; + + let CELSchema { + crates: Crates { + kube_core, + schemars, + serde, + }, + ident, + rules, + } = match CELSchema::from_derive_input(&ast) { + Err(err) => return err.write_errors(), + Ok(attrs) => attrs, + }; + + // Collect global structure validation rules + let struct_name = ident.to_string(); + let struct_rules: Vec = rules.iter().map(|r| quote! {#r,}).collect(); + + // Remove all unknown attributes from the original structure copy + // Has to happen on the original definition at all times, as we don't have #[derive] stanzes. + let attribute_whitelist = ["serde", "schemars", "doc"]; + ast.attrs = remove_attributes(&ast.attrs, &attribute_whitelist); + + let struct_data = match ast.data { + syn::Data::Struct(ref mut struct_data) => struct_data, + _ => return quote! {}, + }; + + // Preserve all serde attributes, to allow #[serde(rename_all = "camelCase")] or similar + let struct_attrs: Vec = ast.attrs.iter().map(|attr| quote! {#attr}).collect(); + let mut property_modifications = vec![]; + if let syn::Fields::Named(fields) = &mut struct_data.fields { + for field in &mut fields.named { + let Rule { rules, .. } = match Rule::from_field(field) { + Ok(rule) => rule, + Err(err) => return err.write_errors(), + }; + + // Remove all unknown attributes from each field + // Has to happen on the original definition at all times, as we don't have #[derive] stanzes. + field.attrs = remove_attributes(&field.attrs, &attribute_whitelist); + + if rules.is_empty() { + continue; + } + + let rules: Vec = rules.iter().map(|r| quote! {#r,}).collect(); + + // We need to prepend derive macros, as they were consumed by this macro processing, being a derive by itself. + property_modifications.push(quote! { + { + #[derive(#serde::Serialize, #schemars::JsonSchema)] + #(#struct_attrs)* + #[automatically_derived] + #[allow(missing_docs)] + struct Validated { + #field + } + + let merge = &mut Validated::json_schema(gen); + #kube_core::validate_property(merge, 0, &[#(#rules)*]).unwrap(); + #kube_core::merge_properties(s, merge); + } + }); + } + } + + quote! { + impl #schemars::JsonSchema for #ident { + fn is_referenceable() -> bool { + false + } + + fn schema_name() -> String { + #struct_name.to_string() + "_kube_validation".into() + } + + fn json_schema(gen: &mut #schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + #[derive(#serde::Serialize, #schemars::JsonSchema)] + #[automatically_derived] + #[allow(missing_docs)] + #ast + + use #kube_core::{Rule, Message, Reason}; + let s = &mut #ident::json_schema(gen); + #kube_core::validate(s, &[#(#struct_rules)*]).unwrap(); + #(#property_modifications)* + s.clone() + } + } + } +} + +// Remove all unknown attributes from the list +fn remove_attributes(attrs: &[Attribute], witelist: &[&str]) -> Vec { + attrs + .iter() + .filter(|attr| witelist.iter().any(|i| attr.path().is_ident(i))) + .cloned() + .collect() +} + +#[test] +fn test_derive_validated() { + let input = quote! { + #[derive(CustomResource, CELSchema, Serialize, Deserialize, Debug, PartialEq, Clone)] + #[kube(group = "clux.dev", version = "v1", kind = "Foo", namespaced)] + #[cel_validate(rule = "self != ''".into())] + struct FooSpec { + #[cel_validate(rule = "self != ''".into())] + foo: String + } + }; + let input = syn::parse2(input).unwrap(); + let v = CELSchema::from_derive_input(&input).unwrap(); + assert_eq!(v.rules.len(), 1); +} + +#[cfg(test)] +mod tests { + use prettyplease::unparse; + use syn::parse::{Parse as _, Parser as _}; + + use super::*; + #[test] + fn test_derive_validated_full() { + let input = quote! { + #[derive(CELSchema)] + #[cel_validate(rule = "true".into())] + struct FooSpec { + #[cel_validate(rule = "true".into())] + foo: String + } + }; + + let expected = quote! { + impl ::schemars::JsonSchema for FooSpec { + fn is_referenceable() -> bool { + false + } + fn schema_name() -> String { + "FooSpec".to_string() + "_kube_validation".into() + } + fn json_schema( + gen: &mut ::schemars::gen::SchemaGenerator, + ) -> schemars::schema::Schema { + #[derive(::serde::Serialize, ::schemars::JsonSchema)] + #[automatically_derived] + #[allow(missing_docs)] + struct FooSpec { + foo: String, + } + use ::kube::core::{Rule, Message, Reason}; + let s = &mut FooSpec::json_schema(gen); + ::kube::core::validate(s, &["true".into()]).unwrap(); + { + #[derive(::serde::Serialize, ::schemars::JsonSchema)] + #[automatically_derived] + #[allow(missing_docs)] + struct Validated { + foo: String, + } + let merge = &mut Validated::json_schema(gen); + ::kube::core::validate_property(merge, 0, &["true".into()]).unwrap(); + ::kube::core::merge_properties(s, merge); + } + s.clone() + } + } + }; + + let output = derive_validated_schema(input); + let output = unparse(&syn::File::parse.parse2(output).unwrap()); + let expected = unparse(&syn::File::parse.parse2(expected).unwrap()); + assert_eq!(output, expected); + } +} diff --git a/kube-derive/src/custom_resource.rs b/kube-derive/src/custom_resource.rs index 055664f31..e12d559a6 100644 --- a/kube-derive/src/custom_resource.rs +++ b/kube-derive/src/custom_resource.rs @@ -1,10 +1,9 @@ // Generated by darling macros, out of our control #![allow(clippy::manual_unwrap_or_default)] - use darling::{FromDeriveInput, FromMeta}; use proc_macro2::{Ident, Literal, Span, TokenStream}; -use quote::{ToTokens, TokenStreamExt}; -use syn::{parse_quote, Data, DeriveInput, Path, Visibility}; +use quote::{ToTokens, TokenStreamExt as _}; +use syn::{parse_quote, Data, DeriveInput, Expr, Path, Visibility}; /// Values we can parse from #[kube(attrs)] #[derive(Debug, FromDeriveInput)] @@ -41,6 +40,8 @@ struct KubeAttrs { annotations: Vec, #[darling(multiple, rename = "label")] labels: Vec, + #[darling(multiple, rename = "rule")] + rules: Vec, /// Sets the `storage` property to `true` or `false`. /// @@ -101,6 +102,8 @@ fn default_served_arg() -> bool { #[derive(Debug, FromMeta)] struct Crates { + #[darling(default = "Self::default_kube")] + kube: Path, #[darling(default = "Self::default_kube_core")] kube_core: Path, #[darling(default = "Self::default_k8s_openapi")] @@ -128,6 +131,10 @@ impl Crates { parse_quote! { ::kube::core } // by default must work well with people using facade crate } + fn default_kube() -> Path { + parse_quote! { ::kube } + } + fn default_k8s_openapi() -> Path { parse_quote! { ::k8s_openapi } } @@ -201,6 +208,7 @@ pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStrea .to_compile_error() } } + let kube_attrs = match KubeAttrs::from_derive_input(&derive_input) { Err(err) => return err.write_errors(), Ok(attrs) => attrs, @@ -223,11 +231,13 @@ pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStrea printcolums, selectable, scale, + rules, storage, served, crates: Crates { kube_core, + kube, k8s_openapi, schemars, serde, @@ -302,15 +312,17 @@ pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStrea // We exclude fields `apiVersion`, `kind`, and `metadata` from our schema because // these are validated by the API server implicitly. Also, we can't generate the // schema for `metadata` (`ObjectMeta`) because it doesn't implement `JsonSchema`. - let schemars_skip = if schema_mode.derive() { - quote! { #[schemars(skip)] } - } else { - quote! {} - }; - if schema_mode.derive() { + let schemars_skip = schema_mode.derive().then_some(quote! { #[schemars(skip)] }); + if schema_mode.derive() && !rules.is_empty() { + derive_paths.push(syn::parse_quote! { #kube::CELSchema }); + } else if schema_mode.derive() { derive_paths.push(syn::parse_quote! { #schemars::JsonSchema }); } + let struct_rules: Option> = + (!rules.is_empty()).then(|| rules.iter().map(|r| quote! {rule = #r,}).collect()); + let struct_rules = struct_rules.map(|r| quote! { #[cel_validate(#(#r)*)]}); + let meta_annotations = if !annotations.is_empty() { quote! { Some(std::collections::BTreeMap::from([#((#annotations.0.to_string(), #annotations.1.to_string()),)*])) } } else { @@ -333,6 +345,7 @@ pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStrea #[derive(#(#derive_paths),*)] #[serde(rename_all = "camelCase")] #[serde(crate = #quoted_serde)] + #struct_rules #visibility struct #rootident { #schemars_skip #visibility metadata: #k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta, diff --git a/kube-derive/src/lib.rs b/kube-derive/src/lib.rs index 36b7df07c..83e008caa 100644 --- a/kube-derive/src/lib.rs +++ b/kube-derive/src/lib.rs @@ -3,6 +3,7 @@ extern crate proc_macro; #[macro_use] extern crate quote; +mod cel_schema; mod custom_resource; mod resource; @@ -160,6 +161,10 @@ mod resource; /// ## `#[kube(served = true)]` /// Sets the `served` property to `true` or `false`. /// +/// ## `#[kube(rule = Rule::new("self == oldSelf").message("field is immutable"))]` +/// Inject a top level CEL validation rule for the top level generated struct. +/// This attribute is for resources deriving [`CELSchema`] instead of [`schemars::JsonSchema`]. +/// /// ## Example with all properties /// /// ```rust @@ -327,6 +332,45 @@ pub fn derive_custom_resource(input: proc_macro::TokenStream) -> proc_macro::Tok custom_resource::derive(proc_macro2::TokenStream::from(input)).into() } +/// Generates a JsonSchema implementation a set of CEL validation rules applied on the CRD. +/// +/// ```rust +/// use kube::CELSchema; +/// use kube::CustomResource; +/// use serde::Deserialize; +/// use serde::Serialize; +/// use kube::core::crd::CustomResourceExt; +/// +/// #[derive(CustomResource, CELSchema, Serialize, Deserialize, Clone, Debug)] +/// #[kube( +/// group = "kube.rs", +/// version = "v1", +/// kind = "Struct", +/// rule = Rule::new("self.matadata.name == 'singleton'"), +/// )] +/// #[cel_validate(rule = Rule::new("self == oldSelf"))] +/// struct MyStruct { +/// #[serde(default = "default")] +/// #[cel_validate(rule = Rule::new("self != ''").message("failure message"))] +/// field: String, +/// } +/// +/// fn default() -> String { +/// "value".into() +/// } +/// +/// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains("x-kubernetes-validations")); +/// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains(r#""rule":"self == oldSelf""#)); +/// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains(r#""rule":"self != ''""#)); +/// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains(r#""message":"failure message""#)); +/// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains(r#""default":"value""#)); +/// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains(r#""rule":"self.matadata.name == 'singleton'""#)); +/// ``` +#[proc_macro_derive(CELSchema, attributes(cel_validate, schemars))] +pub fn derive_schema_validation(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + cel_schema::derive_validated_schema(input.into()).into() +} + /// A custom derive for inheriting Resource impl for the type. /// /// This will generate a [`kube::Resource`] trait implementation, diff --git a/kube-derive/tests/crd_schema_test.rs b/kube-derive/tests/crd_schema_test.rs index e975d8ff3..8e8c5cf07 100644 --- a/kube-derive/tests/crd_schema_test.rs +++ b/kube-derive/tests/crd_schema_test.rs @@ -2,13 +2,14 @@ use assert_json_diff::assert_json_eq; use chrono::{DateTime, Utc}; +use kube::CELSchema; use kube_derive::CustomResource; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; // See `crd_derive_schema` example for how the schema generated from this struct affects defaulting and validation. -#[derive(CustomResource, Serialize, Deserialize, Debug, PartialEq, Clone, JsonSchema)] +#[derive(CustomResource, Serialize, Deserialize, Debug, PartialEq, Clone, CELSchema)] #[kube( group = "clux.dev", version = "v1", @@ -26,8 +27,10 @@ use std::collections::{HashMap, HashSet}; annotation("clux.dev", "cluxingv1"), annotation("clux.dev/firewall", "enabled"), label("clux.dev", "cluxingv1"), - label("clux.dev/persistence", "disabled") + label("clux.dev/persistence", "disabled"), + rule = Rule::new("self.metadata.name == 'singleton'"), )] +#[cel_validate(rule = Rule::new("has(self.nonNullable)"))] #[serde(rename_all = "camelCase")] struct FooSpec { non_nullable: String, @@ -50,6 +53,7 @@ struct FooSpec { timestamp: DateTime, /// This is a complex enum with a description + #[cel_validate(rule = Rule::new("!has(self.variantOne) || self.variantOne.int > 22"))] complex_enum: ComplexEnum, /// This is a untagged enum with a description @@ -303,6 +307,9 @@ fn test_crd_schema_matches_expected() { "required": ["variantThree"] } ], + "x-kubernetes-validations": [{ + "rule": "!has(self.variantOne) || self.variantOne.int > 22", + }], "description": "This is a complex enum with a description" }, "untaggedEnumPerson": { @@ -347,13 +354,19 @@ fn test_crd_schema_matches_expected() { "timestamp", "untaggedEnumPerson" ], + "x-kubernetes-validations": [{ + "rule": "has(self.nonNullable)", + }], "type": "object" } }, "required": [ "spec" ], - "title": "Foo", + "x-kubernetes-validations": [{ + "rule": "self.metadata.name == 'singleton'", + }], + "title": "Foo_kube_validation", "type": "object" } }, diff --git a/kube/src/lib.rs b/kube/src/lib.rs index e7be35690..1cb9f23c4 100644 --- a/kube/src/lib.rs +++ b/kube/src/lib.rs @@ -168,6 +168,10 @@ pub use kube_derive::CustomResource; #[cfg_attr(docsrs, doc(cfg(feature = "derive")))] pub use kube_derive::Resource; +#[cfg(feature = "derive")] +#[cfg_attr(docsrs, doc(cfg(feature = "derive")))] +pub use kube_derive::CELSchema; + #[cfg(feature = "runtime")] #[cfg_attr(docsrs, doc(cfg(feature = "runtime")))] #[doc(inline)] From 76b4af6a918bf542ef307c2e28180c5b3e5ef040 Mon Sep 17 00:00:00 2001 From: Eirik A Date: Mon, 23 Dec 2024 07:41:05 +0000 Subject: [PATCH 06/11] Bump `k8s-openapi` for Kubernetes `v1_32` support and MSRV (#1671) * Bump k8s-openapi for Kubernetes v1_32 support Signed-off-by: clux * Make just bump-k8s more reliable and parametrise k3s versions in CI Technically expands our testing range a little bit, but it simplifies our gunk here. Signed-off-by: clux * ci debug Signed-off-by: clux * unparametrise, github does not support it ..nor does it support yaml anchors so back to hacks. Signed-off-by: clux * Use a better replace due to gh not supporting env in params Signed-off-by: clux * leftover env Signed-off-by: clux * leftover env addition Signed-off-by: clux * Bump MSRV to fix CI brought in by home dep, but it's within our policy. Signed-off-by: clux * should be consistent and use earliest in cargo.tomls Signed-off-by: clux * remove ci check for specific e2e/cargo.toml openapi feature given change to floating 'earliest' tag. Signed-off-by: clux --------- Signed-off-by: clux --- .devcontainer/Dockerfile | 2 +- .github/workflows/ci.yml | 10 ++++------ .github/workflows/coverage.yml | 2 +- Cargo.toml | 4 ++-- README.md | 4 ++-- e2e/Cargo.toml | 2 +- justfile | 18 ++++++++++-------- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 61585a9a7..64abcf7e9 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM docker.io/rust:1.77.2-bullseye +FROM docker.io/rust:1.81.0-bullseye ENV DEBIAN_FRONTEND=noninteractive RUN apt update && apt upgrade -y diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb4e728fd..98569bab4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,7 +132,9 @@ jobs: fail-fast: false matrix: # Run these tests against older clusters as well - k8s: [v1.26, v1.30] + k8s: + - "v1.28" # MK8SV + - "latest" steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -184,10 +186,6 @@ jobs: echo "mk8sv not set correctly in tests" exit 1 fi - if ! grep "${{ steps.mk8sv.outputs.mk8svdash }}" e2e/Cargo.toml | grep mk8sv; then - echo "mk8sv not set correctly in e2e features" - exit 1 - fi - uses: dtolnay/rust-toolchain@stable # Smart caching for Rust projects. @@ -198,7 +196,7 @@ jobs: - uses: nolar/setup-k3d-k3s@v1 with: - version: v1.26 + version: "v1.28" # MK8SV # k3d-kube k3d-name: kube # Used to avoid rate limits when fetching the releases from k3s repo. diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 89d29ba61..b55775f99 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -26,7 +26,7 @@ jobs: tool: cargo-tarpaulin@0.28.0 - uses: nolar/setup-k3d-k3s@v1 with: - version: v1.26 + version: "v1.28" # MK8SV # k3d-kube k3d-name: kube # Used to avoid rate limits when fetching the releases from k3s repo. diff --git a/Cargo.toml b/Cargo.toml index ed419bfd5..1c48bf682 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ repository = "https://github.com/kube-rs/kube" readme = "README.md" license = "Apache-2.0" edition = "2021" -rust-version = "1.77.2" +rust-version = "1.81.0" [workspace.lints.rust] unsafe_code = "forbid" @@ -61,7 +61,7 @@ hyper-util = "0.1.9" json-patch = "3" jsonpath-rust = "0.7.3" jsonptr = "0.6" -k8s-openapi = { version = "0.23.0", default-features = false } +k8s-openapi = { version = "0.24.0", default-features = false } openssl = "0.10.36" parking_lot = "0.12.0" pem = "3.0.1" diff --git a/README.md b/README.md index 54b7c0fea..591331e03 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # kube-rs [![Crates.io](https://img.shields.io/crates/v/kube.svg)](https://crates.io/crates/kube) -[![Rust 1.77](https://img.shields.io/badge/MSRV-1.77-dea584.svg)](https://github.com/rust-lang/rust/releases/tag/1.77.2) -[![Tested against Kubernetes v1_26 and above](https://img.shields.io/badge/MK8SV-v1_26-326ce5.svg)](https://kube.rs/kubernetes-version) +[![Rust 1.81](https://img.shields.io/badge/MSRV-1.81-dea584.svg)](https://github.com/rust-lang/rust/releases/tag/1.81.0) +[![Tested against Kubernetes v1.28 and above](https://img.shields.io/badge/MK8SV-v1.28-326ce5.svg)](https://kube.rs/kubernetes-version) [![Best Practices](https://bestpractices.coreinfrastructure.org/projects/5413/badge)](https://bestpractices.coreinfrastructure.org/projects/5413) [![Discord chat](https://img.shields.io/discord/500028886025895936.svg?logo=discord&style=plastic)](https://discord.gg/tokio) diff --git a/e2e/Cargo.toml b/e2e/Cargo.toml index a37539cd6..1e048f18c 100644 --- a/e2e/Cargo.toml +++ b/e2e/Cargo.toml @@ -19,7 +19,7 @@ path = "boot.rs" [features] latest = ["k8s-openapi/latest"] -mk8sv = ["k8s-openapi/v1_26"] +mk8sv = ["k8s-openapi/earliest"] rustls = ["kube/rustls-tls"] openssl = ["kube/openssl-tls"] diff --git a/justfile b/justfile index 9fe9a393b..7210b80a3 100644 --- a/justfile +++ b/justfile @@ -113,16 +113,18 @@ bump-msrv msrv: sd "^.+badge/MSRV.+$" "${badge}" README.md sd "rust:.*-bullseye" "rust:{{msrv}}-bullseye" .devcontainer/Dockerfile -# Increment the Kubernetes feature version from k8s-openapi for tests; "just bump-k8s" +# Sets the Kubernetes feature version from latest k8s-openapi. bump-k8s: #!/usr/bin/env bash - latest=$(cargo tree --format "{f}" -i k8s-openapi | head -n 1 | choose -f ',' 1) - # bumping supported version also bumps our mk8sv - mk8svnew=${latest::-2}$((${latest:3} - 5)) - mk8svold=${latest::-2}$((${latest:3} - 6)) - fastmod -m -d e2e -e toml "$mk8svold" "$mk8svnew" - fastmod -m -d .github/workflows -e yml "${mk8svold/_/\.}" "${mk8svnew/_/.}" + earliest=$(cargo info k8s-openapi --color=never 2> /dev/null |grep earliest | awk -F'[][]' '{print $2}') + latest=$(cargo info k8s-openapi --color=never 2> /dev/null |grep latest | awk -F'[][]' '{print $2}') + # pin mk8sv to k8s-openapi earliest + min_feat="${earliest::-2}${earliest:3}" + min_dots="${min_feat/_/.}" + echo "Setting MK8SV to $min_dots using feature $min_feat" + # workflow pins for k3s (any line with key/array suffixed by # MK8SV) + sd "(.*)([\:\-]{1}) .* # MK8SV$" "\$1\$2 \"${min_dots}\" # MK8SV" .github/workflows/*.yml # bump mk8sv badge - badge="[![Tested against Kubernetes ${mk8svnew} and above](https://img.shields.io/badge/MK8SV-${mk8svnew}-326ce5.svg)](https://kube.rs/kubernetes-version)" + badge="[![Tested against Kubernetes ${min_dots} and above](https://img.shields.io/badge/MK8SV-${min_dots}-326ce5.svg)](https://kube.rs/kubernetes-version)" sd "^.+badge/MK8SV.+$" "${badge}" README.md echo "remember to bump kubernetes-version.md in kube-rs/website" From 000e99a56beeda9b897a343e7a3c7bfada77f13d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Dec 2024 07:44:55 +0000 Subject: [PATCH 07/11] Update garde requirement from 0.20.0 to 0.21.0 (#1672) Updates the requirements on [garde](https://github.com/jprochazk/garde) to permit the latest version. - [Release notes](https://github.com/jprochazk/garde/releases) - [Commits](https://github.com/jprochazk/garde/compare/v0.20.0...v0.21.0) --- updated-dependencies: - dependency-name: garde dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Eirik A --- examples/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 25add42a2..5481d5d58 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -24,7 +24,7 @@ latest = ["k8s-openapi/latest"] [dev-dependencies] tokio-util.workspace = true assert-json-diff.workspace = true -garde = { version = "0.20.0", default-features = false, features = ["derive"] } +garde = { version = "0.21.0", default-features = false, features = ["derive"] } anyhow.workspace = true futures = { workspace = true, features = ["async-await"] } jsonpath-rust.workspace = true From 68a46a4efb09c0ed0a2c16f37b76059afcfbe0b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Dec 2024 08:02:39 +0000 Subject: [PATCH 08/11] Update tokio-tungstenite requirement from 0.24.0 to 0.25.0 (#1666) * Update tokio-tungstenite requirement from 0.24.0 to 0.25.0 Updates the requirements on [tokio-tungstenite](https://github.com/snapview/tokio-tungstenite) to permit the latest version. - [Changelog](https://github.com/snapview/tokio-tungstenite/blob/master/CHANGELOG.md) - [Commits](https://github.com/snapview/tokio-tungstenite/compare/v0.24.0...v0.24.0) --- updated-dependencies: - dependency-name: tokio-tungstenite dependency-type: direct:production ... Signed-off-by: dependabot[bot] * add some into calls to fix upgrade Signed-off-by: clux * fix clippy lint on useless from Signed-off-by: clux --------- Signed-off-by: dependabot[bot] Signed-off-by: clux Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: clux --- Cargo.toml | 2 +- kube-client/src/api/portforward.rs | 3 +-- kube-client/src/api/remote_command.rs | 8 ++++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1c48bf682..d35c45d32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,7 +83,7 @@ tempfile = "3.1.0" thiserror = "2.0.3" tokio = "1.14.0" tokio-test = "0.4.0" -tokio-tungstenite = "0.24.0" +tokio-tungstenite = "0.26.1" tokio-util = "0.7.0" tower = "0.5.1" tower-http = "0.6.1" diff --git a/kube-client/src/api/portforward.rs b/kube-client/src/api/portforward.rs index 6439f9dc7..e28ffbc22 100644 --- a/kube-client/src/api/portforward.rs +++ b/kube-client/src/api/portforward.rs @@ -228,8 +228,7 @@ where .map_err(Error::ReceiveWebSocketMessage)? { match msg { - ws::Message::Binary(bin) if bin.len() > 1 => { - let mut bytes = Bytes::from(bin); + ws::Message::Binary(mut bytes) if bytes.len() > 1 => { let ch = bytes.split_to(1)[0]; sender .send(Message::FromPod(ch, bytes)) diff --git a/kube-client/src/api/remote_command.rs b/kube-client/src/api/remote_command.rs index a0a78572a..069ffb440 100644 --- a/kube-client/src/api/remote_command.rs +++ b/kube-client/src/api/remote_command.rs @@ -350,7 +350,7 @@ where let mut vec = Vec::with_capacity(new_size.len() + 1); vec.push(RESIZE_CHANNEL); vec.extend_from_slice(&new_size[..]); - server_send.send(ws::Message::Binary(vec)).await.map_err(Error::SendTerminalSize)?; + server_send.send(ws::Message::Binary(vec.into())).await.map_err(Error::SendTerminalSize)?; }, None => { have_terminal_size_rx = false; @@ -379,9 +379,9 @@ async fn filter_message(wsm: Result) -> Option 1 => match bin[0] { - STDOUT_CHANNEL => Some(Ok(Message::Stdout(bin))), - STDERR_CHANNEL => Some(Ok(Message::Stderr(bin))), - STATUS_CHANNEL => Some(Ok(Message::Status(bin))), + STDOUT_CHANNEL => Some(Ok(Message::Stdout(bin.into()))), + STDERR_CHANNEL => Some(Ok(Message::Stderr(bin.into()))), + STATUS_CHANNEL => Some(Ok(Message::Status(bin.into()))), // We don't receive messages to stdin and resize channels. _ => None, }, From abcfed41d2f7d115277b9fd7129a9a564f65065b Mon Sep 17 00:00:00 2001 From: clux Date: Mon, 23 Dec 2024 12:29:40 +0000 Subject: [PATCH 09/11] fix readme version sync error Signed-off-by: clux --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 591331e03..97688f409 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Select a version of `kube` along with the generated [k8s-openapi](https://github ```toml [dependencies] kube = { version = "0.97.0", features = ["runtime", "derive"] } -k8s-openapi = { version = "0.23.0", features = ["latest"] } +k8s-openapi = { version = "0.24.0", features = ["latest"] } ``` See [features](https://kube.rs/features/) for a quick overview of default-enabled / opt-in functionality. @@ -157,7 +157,7 @@ By default [rustls](https://github.com/rustls/rustls) is used for TLS, but `open ```toml [dependencies] kube = { version = "0.97.0", default-features = false, features = ["client", "openssl-tls"] } -k8s-openapi = { version = "0.23.0", features = ["latest"] } +k8s-openapi = { version = "0.24.0", features = ["latest"] } ``` This will pull in `openssl` and `hyper-openssl`. If `default-features` is left enabled, you will pull in two TLS stacks, and the default will remain as `rustls`. From 3f122f9c4650b33abca9d4f25db1aaf205e7a00c Mon Sep 17 00:00:00 2001 From: clux Date: Mon, 23 Dec 2024 12:30:34 +0000 Subject: [PATCH 10/11] release 0.98.0 --- CHANGELOG.md | 21 ++++++++++++--------- Cargo.toml | 2 +- README.md | 4 ++-- e2e/Cargo.toml | 2 +- examples/Cargo.toml | 4 ++-- kube-client/Cargo.toml | 2 +- kube-derive/README.md | 2 +- kube-runtime/Cargo.toml | 2 +- kube/Cargo.toml | 8 ++++---- 9 files changed, 25 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45ae1040e..da93bf703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,10 @@ UNRELEASED =================== - * see https://github.com/kube-rs/kube/compare/0.97.0...main + * see https://github.com/kube-rs/kube/compare/0.98.0...main + +0.98.0 / 2024-12-23 +=================== [0.97.0](https://github.com/kube-rs/kube/releases/tag/0.97.0) / 2024-11-20 =================== @@ -14,22 +17,22 @@ UNRELEASED ## Highlights - [`CustomResource`](https://docs.rs/kube/latest/kube/derive.CustomResource.html) derive added features for crd yaml output: - * selectable fields #1605 + #1610 - * annotations and labels #1631 + * selectable fields [#1605](https://github.com/kube-rs/kube/issues/1605) + [#1610](https://github.com/kube-rs/kube/issues/1610) + * annotations and labels [#1631](https://github.com/kube-rs/kube/issues/1631) - Configuration edge cases: - * Avoid double installations of `aws-lc-rs` (rustls crypto) provider #1617 - * Kubeconfig fix for `null` user; #1608 - * Default runtime watcher backoff alignment with `client-go` #1603 + * Avoid double installations of `aws-lc-rs` (rustls crypto) provider [#1617](https://github.com/kube-rs/kube/issues/1617) + * Kubeconfig fix for `null` user; [#1608](https://github.com/kube-rs/kube/issues/1608) + * Default runtime watcher backoff alignment with `client-go` [#1603](https://github.com/kube-rs/kube/issues/1603) - Feature use: - * Client proxy feature-set misuse prevention #1626 - * Allow disabling `gzip` via `Config` #1627 + * Client proxy feature-set misuse prevention [#1626](https://github.com/kube-rs/kube/issues/1626) + * Allow disabling `gzip` via `Config` [#1627](https://github.com/kube-rs/kube/issues/1627) - Depedency minors: `thiserror`, `hashbrown`, `jsonptr`, `json-patch`. Killed `lazy_static` / `once_cell` ## What's Changed ### Added * Feature: Allow to pass selectableFields for CRD definition by @Danil-Grigorev in https://github.com/kube-rs/kube/pull/1605 * add support for CRD annotations and labels in kube-derive by @verokarhu in https://github.com/kube-rs/kube/pull/1631 -* Feature: Add config setting to disable gzip compression #1627 by @markdingram in https://github.com/kube-rs/kube/pull/1628 +* Feature: Add config setting to disable gzip compression [#1627](https://github.com/kube-rs/kube/issues/1627) by @markdingram in https://github.com/kube-rs/kube/pull/1628 ### Changed * upgrade to hashbrown 0.15.0 by @rorosen in https://github.com/kube-rs/kube/pull/1599 * update jsonptr + json-patch by @aviramha in https://github.com/kube-rs/kube/pull/1600 diff --git a/Cargo.toml b/Cargo.toml index d35c45d32..906fece31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ members = [ ] [workspace.package] -version = "0.97.0" +version = "0.98.0" authors = [ "clux ", "Natalie Klestrup Röijezon ", diff --git a/README.md b/README.md index 97688f409..37b18e569 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Select a version of `kube` along with the generated [k8s-openapi](https://github ```toml [dependencies] -kube = { version = "0.97.0", features = ["runtime", "derive"] } +kube = { version = "0.98.0", features = ["runtime", "derive"] } k8s-openapi = { version = "0.24.0", features = ["latest"] } ``` @@ -156,7 +156,7 @@ By default [rustls](https://github.com/rustls/rustls) is used for TLS, but `open ```toml [dependencies] -kube = { version = "0.97.0", default-features = false, features = ["client", "openssl-tls"] } +kube = { version = "0.98.0", default-features = false, features = ["client", "openssl-tls"] } k8s-openapi = { version = "0.24.0", features = ["latest"] } ``` diff --git a/e2e/Cargo.toml b/e2e/Cargo.toml index 1e048f18c..1d11d8da2 100644 --- a/e2e/Cargo.toml +++ b/e2e/Cargo.toml @@ -27,7 +27,7 @@ openssl = ["kube/openssl-tls"] anyhow.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -kube = { path = "../kube", version = "^0.97.0", default-features = false, features = ["client", "runtime", "ws", "admission", "gzip"] } +kube = { path = "../kube", version = "^0.98.0", default-features = false, features = ["client", "runtime", "ws", "admission", "gzip"] } k8s-openapi.workspace = true serde_json.workspace = true tokio = { workspace = true, features = ["full"] } diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 5481d5d58..f260bc73b 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -29,8 +29,8 @@ anyhow.workspace = true futures = { workspace = true, features = ["async-await"] } jsonpath-rust.workspace = true jsonptr.workspace = true -kube = { path = "../kube", version = "^0.97.0", default-features = false, features = ["admission"] } -kube-derive = { path = "../kube-derive", version = "^0.97.0", default-features = false } # only needed to opt out of schema +kube = { path = "../kube", version = "^0.98.0", default-features = false, features = ["admission"] } +kube-derive = { path = "../kube-derive", version = "^0.98.0", default-features = false } # only needed to opt out of schema k8s-openapi.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true diff --git a/kube-client/Cargo.toml b/kube-client/Cargo.toml index 8520e278e..30de09717 100644 --- a/kube-client/Cargo.toml +++ b/kube-client/Cargo.toml @@ -60,7 +60,7 @@ rustls = { workspace = true, optional = true } rustls-pemfile = { workspace = true, optional = true } bytes = { workspace = true, optional = true } tokio = { workspace = true, features = ["time", "signal", "sync"], optional = true } -kube-core = { path = "../kube-core", version = "=0.97.0" } +kube-core = { path = "../kube-core", version = "=0.98.0" } jsonpath-rust = { workspace = true, optional = true } tokio-util = { workspace = true, features = ["io", "codec"], optional = true } hyper = { workspace = true, features = ["client", "http1"], optional = true } diff --git a/kube-derive/README.md b/kube-derive/README.md index d7536a471..b0e7664b5 100644 --- a/kube-derive/README.md +++ b/kube-derive/README.md @@ -6,7 +6,7 @@ Add the `derive` feature to `kube`: ```toml [dependencies] -kube = { version = "0.97.0", feature = ["derive"] } +kube = { version = "0.98.0", feature = ["derive"] } ``` ## Usage diff --git a/kube-runtime/Cargo.toml b/kube-runtime/Cargo.toml index 9006bd819..959601641 100644 --- a/kube-runtime/Cargo.toml +++ b/kube-runtime/Cargo.toml @@ -30,7 +30,7 @@ rust.unsafe_code = "forbid" [dependencies] futures = { workspace = true, features = ["async-await"] } -kube-client = { path = "../kube-client", version = "=0.97.0", default-features = false, features = ["jsonpatch", "client"] } +kube-client = { path = "../kube-client", version = "=0.98.0", default-features = false, features = ["jsonpatch", "client"] } educe = { workspace = true, features = ["Clone", "Debug", "Hash", "PartialEq"] } serde.workspace = true ahash.workspace = true diff --git a/kube/Cargo.toml b/kube/Cargo.toml index c3a236e98..1fb3b41a9 100644 --- a/kube/Cargo.toml +++ b/kube/Cargo.toml @@ -48,10 +48,10 @@ rustdoc-args = ["--cfg", "docsrs"] workspace = true [dependencies] -kube-derive = { path = "../kube-derive", version = "=0.97.0", optional = true } -kube-core = { path = "../kube-core", version = "=0.97.0" } -kube-client = { path = "../kube-client", version = "=0.97.0", default-features = false, optional = true } -kube-runtime = { path = "../kube-runtime", version = "=0.97.0", optional = true} +kube-derive = { path = "../kube-derive", version = "=0.98.0", optional = true } +kube-core = { path = "../kube-core", version = "=0.98.0" } +kube-client = { path = "../kube-client", version = "=0.98.0", default-features = false, optional = true } +kube-runtime = { path = "../kube-runtime", version = "=0.98.0", optional = true} # Not used directly, but required by resolver 2.0 to ensure that the k8s-openapi dependency # is considered part of the "deps" graph rather than just the "dev-deps" graph k8s-openapi.workspace = true From 6a980c6ea50f2f3f4f2867d3df3fd77be00fca84 Mon Sep 17 00:00:00 2001 From: clux Date: Mon, 23 Dec 2024 12:57:11 +0000 Subject: [PATCH 11/11] import 0.98 changelog Signed-off-by: clux --- CHANGELOG.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da93bf703..0596f23e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,31 @@ UNRELEASED =================== * see https://github.com/kube-rs/kube/compare/0.98.0...main -0.98.0 / 2024-12-23 +[0.98.0](https://github.com/kube-rs/kube/releases/tag/0.98.0) / 2024-12-23 =================== + + +## Highlights + +- [Kubernetes `v1_32`](https://kubernetes.io/blog/2024/12/11/kubernetes-v1-32-release/) support via `k8s-openapi` [0.24](https://github.com/Arnavion/k8s-openapi/releases/tag/v0.24.0) + * Please [upgrade k8s-openapi along with kube](https://kube.rs/upgrading/) to avoid conflicts. + * New minimum versions: [MSRV](https://kube.rs/rust-version/) 1.81.0, [MK8SV](https://kube.rs/kubernetes-version/): 1.28 +- `kube-derive` additions: + * A [`CELSchema`](https://docs.rs/kube/latest/kube/derive.CELSchema.html) derive macro wrapper around [`JsonSchema`](https://docs.rs/schemars/latest/schemars/trait.JsonSchema.html) for injecting [cel validations](https://kubernetes.io/docs/reference/using-api/cel/) into the schema [#1649](https://github.com/kube-rs/kube/pull/1649) + * Allow overriding `served` and `storage` booleans for [multiple versions](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/#specify-multiple-versions) of [`CustomResource`](https://docs.rs/kube/latest/kube/derive.CustomResource.html) derives: [#1644](https://github.com/kube-rs/kube/pull/1644) +- `kube-runtime` event [`Recorder`](https://docs.rs/kube/latest/kube/runtime/events/struct.Recorder.html) now aggregates repeat events [#1655](https://github.com/kube-rs/kube/pull/1655) (some breaking changes, see [controller-rs#116](https://github.com/kube-rs/controller-rs/pull/116)) +- `kube-client` UTF-16 edge case handling for windows [#1654](https://github.com/kube-rs/kube/pull/1654) + +## What's Changed +### Added +* Add `storage` and `served` argument to derive macro by @Techassi in https://github.com/kube-rs/kube/pull/1644 +* Implement `derive(CELSchema)` macro for generating cel validation on CRDs by @Danil-Grigorev in https://github.com/kube-rs/kube/pull/1649 +### Changed +* Add series implementation for `runtime` event recorder by @pando85 in https://github.com/kube-rs/kube/pull/1655 +* Bump `k8s-openapi` for Kubernetes `v1_32` support and MSRV by @clux in https://github.com/kube-rs/kube/pull/1671 +* Update tokio-tungstenite requirement from 0.24.0 to 0.25.0 by @dependabot in https://github.com/kube-rs/kube/pull/1666 +### Fixed +* Add support for UTF-16 encoded kubeconfig files by @goenning in https://github.com/kube-rs/kube/pull/1654 [0.97.0](https://github.com/kube-rs/kube/releases/tag/0.97.0) / 2024-11-20 ===================