|
| 1 | +use std::{sync::Arc, time::Duration}; |
| 2 | + |
| 3 | +use futures::StreamExt; |
| 4 | +use k8s_openapi::api::core::v1::{Pod, PodCondition}; |
| 5 | +use kube::{ |
| 6 | + api::{Patch, PatchParams}, |
| 7 | + runtime::{ |
| 8 | + controller::Action, |
| 9 | + reflector::{self}, |
| 10 | + watcher, Config, Controller, WatchStreamExt, |
| 11 | + }, |
| 12 | + Api, Client, ResourceExt, |
| 13 | +}; |
| 14 | +use tracing::{debug, error, info, warn}; |
| 15 | + |
| 16 | +use thiserror::Error; |
| 17 | + |
| 18 | +// Helper module that namespaces two constants describing a Kubernetes status condition |
| 19 | +pub mod condition { |
| 20 | + pub static UNDOCUMENTED_TYPE: &str = "UndocumentedPort"; |
| 21 | + pub static STATUS_TRUE: &str = "True"; |
| 22 | +} |
| 23 | + |
| 24 | +const SUBSCRIBE_BUFFER_SIZE: usize = 256; |
| 25 | + |
| 26 | +#[derive(Debug, Error)] |
| 27 | +enum Error { |
| 28 | + #[error("Failed to patch pod: {0}")] |
| 29 | + WriteFailed(#[source] kube::Error), |
| 30 | + |
| 31 | + #[error("Missing po field: {0}")] |
| 32 | + MissingField(&'static str), |
| 33 | +} |
| 34 | + |
| 35 | +#[derive(Clone)] |
| 36 | +struct Data { |
| 37 | + client: Client, |
| 38 | +} |
| 39 | + |
| 40 | +/// A simple reconciliation function that will copy a pod's labels into the annotations. |
| 41 | +async fn reconcile_metadata(pod: Arc<Pod>, ctx: Arc<Data>) -> Result<Action, Error> { |
| 42 | + let namespace = &pod.namespace().unwrap_or_default(); |
| 43 | + if namespace == "kube-system" { |
| 44 | + return Ok(Action::await_change()); |
| 45 | + } |
| 46 | + |
| 47 | + let mut pod = (*pod).clone(); |
| 48 | + pod.metadata.managed_fields = None; |
| 49 | + // combine labels and annotations into a new map |
| 50 | + let labels = pod.labels().clone().into_iter(); |
| 51 | + pod.annotations_mut().extend(labels); |
| 52 | + |
| 53 | + let pod_api = Api::<Pod>::namespaced( |
| 54 | + ctx.client.clone(), |
| 55 | + pod.metadata |
| 56 | + .namespace |
| 57 | + .as_ref() |
| 58 | + .ok_or_else(|| Error::MissingField(".metadata.name"))?, |
| 59 | + ); |
| 60 | + |
| 61 | + pod_api |
| 62 | + .patch( |
| 63 | + &pod.name_any(), |
| 64 | + &PatchParams::apply("controller-1"), |
| 65 | + &Patch::Apply(&pod), |
| 66 | + ) |
| 67 | + .await |
| 68 | + .map_err(Error::WriteFailed)?; |
| 69 | + |
| 70 | + Ok(Action::requeue(Duration::from_secs(300))) |
| 71 | +} |
| 72 | + |
| 73 | +/// Another reconiliation function that will add an 'UndocumentedPort' condition to pods that do |
| 74 | +/// do not have any ports declared across all containers. |
| 75 | +async fn reconcile_status(pod: Arc<Pod>, ctx: Arc<Data>) -> Result<Action, Error> { |
| 76 | + for container in pod.spec.clone().unwrap_or_default().containers.iter() { |
| 77 | + if container.ports.clone().unwrap_or_default().len() != 0 { |
| 78 | + debug!(name = %pod.name_any(), "Skipped updating pod with documented ports"); |
| 79 | + return Ok(Action::await_change()); |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + let pod_api = Api::<Pod>::namespaced( |
| 84 | + ctx.client.clone(), |
| 85 | + pod.metadata |
| 86 | + .namespace |
| 87 | + .as_ref() |
| 88 | + .ok_or_else(|| Error::MissingField(".metadata.name"))?, |
| 89 | + ); |
| 90 | + |
| 91 | + let undocumented_condition = PodCondition { |
| 92 | + type_: condition::UNDOCUMENTED_TYPE.into(), |
| 93 | + status: condition::STATUS_TRUE.into(), |
| 94 | + ..Default::default() |
| 95 | + }; |
| 96 | + let value = serde_json::json!({ |
| 97 | + "status": { |
| 98 | + "name": pod.name_any(), |
| 99 | + "kind": "Pod", |
| 100 | + "conditions": vec![undocumented_condition] |
| 101 | + } |
| 102 | + }); |
| 103 | + pod_api |
| 104 | + .patch_status( |
| 105 | + &pod.name_any(), |
| 106 | + &PatchParams::apply("controller-2"), |
| 107 | + &Patch::Strategic(value), |
| 108 | + ) |
| 109 | + .await |
| 110 | + .map_err(Error::WriteFailed)?; |
| 111 | + |
| 112 | + Ok(Action::requeue(Duration::from_secs(300))) |
| 113 | +} |
| 114 | + |
| 115 | +fn error_policy(obj: Arc<Pod>, error: &Error, _ctx: Arc<Data>) -> Action { |
| 116 | + error!(%error, name = %obj.name_any(), "Failed reconciliation"); |
| 117 | + Action::requeue(Duration::from_secs(10)) |
| 118 | +} |
| 119 | + |
| 120 | +#[tokio::main] |
| 121 | +async fn main() -> anyhow::Result<()> { |
| 122 | + tracing_subscriber::fmt::init(); |
| 123 | + |
| 124 | + let client = Client::try_default().await?; |
| 125 | + let pods = Api::<Pod>::namespaced(client.clone(), "default"); |
| 126 | + let config = Config::default().concurrency(2); |
| 127 | + let ctx = Arc::new(Data { client }); |
| 128 | + |
| 129 | + // Create a shared store with a predefined buffer that will be shared between subscribers. |
| 130 | + let (reader, writer) = reflector::store_shared(SUBSCRIBE_BUFFER_SIZE); |
| 131 | + // Before threading an object watch through the store, create a subscriber. |
| 132 | + // Any number of subscribers can be created from one writer. |
| 133 | + let subscriber = writer |
| 134 | + .subscribe() |
| 135 | + .expect("subscribers can only be created from shared stores"); |
| 136 | + |
| 137 | + // Reflect a stream of pod watch events into the store and apply a backoff. For subscribers to |
| 138 | + // be able to consume updates, the reflector must be shared. |
| 139 | + let pod_watch = watcher(pods.clone(), Default::default()) |
| 140 | + .default_backoff() |
| 141 | + .reflect_shared(writer) |
| 142 | + .for_each(|res| async move { |
| 143 | + match res { |
| 144 | + Ok(event) => debug!("Received event on root stream {event:?}"), |
| 145 | + Err(error) => error!(%error, "Unexpected error when watching resource"), |
| 146 | + } |
| 147 | + }); |
| 148 | + |
| 149 | + // Create the first controller using the reconcile_metadata function. Controllers accept |
| 150 | + // subscribers through a dedicated interface. |
| 151 | + let metadata_controller = Controller::for_shared_stream(subscriber.clone(), reader) |
| 152 | + .with_config(config.clone()) |
| 153 | + .shutdown_on_signal() |
| 154 | + .run(reconcile_metadata, error_policy, ctx.clone()) |
| 155 | + .for_each(|res| async move { |
| 156 | + match res { |
| 157 | + Ok(v) => info!("Reconciled metadata {v:?}"), |
| 158 | + Err(error) => warn!(%error, "Failed to reconcile metadata"), |
| 159 | + } |
| 160 | + }); |
| 161 | + |
| 162 | + // Subscribers can be used to get a read handle on the store, if the initial handle has been |
| 163 | + // moved or dropped. |
| 164 | + let reader = subscriber.reader(); |
| 165 | + // Create the second controller using the reconcile_status function. |
| 166 | + let status_controller = Controller::for_shared_stream(subscriber, reader) |
| 167 | + .with_config(config) |
| 168 | + .shutdown_on_signal() |
| 169 | + .run(reconcile_status, error_policy, ctx) |
| 170 | + .for_each(|res| async move { |
| 171 | + match res { |
| 172 | + Ok(v) => info!("Reconciled status {v:?}"), |
| 173 | + Err(error) => warn!(%error, "Failed to reconcile status"), |
| 174 | + } |
| 175 | + }); |
| 176 | + |
| 177 | + // Drive streams to readiness. The initial watch (that is reflected) needs to be driven to |
| 178 | + // consume events from the API Server and forward them to subscribers. |
| 179 | + // |
| 180 | + // Both controllers will operate on shared objects. |
| 181 | + tokio::select! { |
| 182 | + _ = futures::future::join(metadata_controller, status_controller) => {}, |
| 183 | + _ = pod_watch => {} |
| 184 | + } |
| 185 | + |
| 186 | + Ok(()) |
| 187 | +} |
0 commit comments