Skip to main content

kaniop_operator/controller/
context.rs

1use super::{
2    ControllerId, KanidmClients, idm_reconcile_interval,
3    kanidm::{ClientLockKey, KanidmKey, KanidmResource, KanidmUser},
4};
5
6use crate::kanidm::crd::Kanidm;
7use crate::metrics::ControllerMetrics;
8use kaniop_k8s_util::error::{Error, Result};
9
10use kaniop_k8s_util::types::short_type_name;
11
12use std::collections::HashMap;
13use std::sync::Arc;
14
15use backon::{BackoffBuilder, ExponentialBackoff, ExponentialBuilder};
16use k8s_openapi::{NamespaceResourceScope, api::core::v1::Namespace};
17use kanidm_client::KanidmClient;
18use kube::runtime::events::{Event, EventType, Recorder};
19use kube::{Api, client::Client};
20use kube::{Resource, ResourceExt};
21use kube::{
22    api::{Patch, PatchParams},
23    runtime::reflector::{Lookup, ObjectRef, Store},
24};
25use serde::{Deserialize, Serialize};
26use tokio::sync::{Mutex, RwLock};
27use tokio::time::Duration;
28use tracing::{debug, error, info, trace};
29
30// Context for our reconciler
31#[derive(Clone)]
32pub struct Context<K: Resource> {
33    /// Controller ID
34    pub controller_id: ControllerId,
35    /// Kubernetes client
36    pub client: Client,
37    /// Prometheus metrics
38    pub metrics: Arc<ControllerMetrics>,
39    /// State of the error backoff policy per object
40    error_backoff_cache: Arc<RwLock<HashMap<ObjectRef<K>, RwLock<ExponentialBackoff>>>>,
41    /// Event recorder
42    pub recorder: Recorder,
43    /// Cache for Namespace resources
44    pub namespace_store: Store<Namespace>,
45    /// Cache for Kanidm resources
46    pub kanidm_store: Store<Kanidm>,
47    /// Shared Kanidm cache clients with the ability to manage users and their groups
48    idm_clients: Arc<RwLock<KanidmClients>>,
49    /// Shared Kanidm cache clients with the ability to manage the operation of Kanidm as a
50    /// database and service
51    system_clients: Arc<RwLock<KanidmClients>>,
52    /// Locks for client creation to prevent thundering herd problem
53    client_creation_locks: Arc<RwLock<HashMap<ClientLockKey, Arc<Mutex<()>>>>>,
54}
55
56impl<K> Context<K>
57where
58    K: Resource + ResourceExt + Lookup + Clone + 'static,
59    <K as Lookup>::DynamicType: Eq + std::hash::Hash + Clone,
60{
61    #[allow(clippy::too_many_arguments)]
62    pub fn new(
63        controller_id: ControllerId,
64        client: Client,
65        metrics: Arc<ControllerMetrics>,
66        recorder: Recorder,
67        idm_clients: Arc<RwLock<KanidmClients>>,
68        system_clients: Arc<RwLock<KanidmClients>>,
69        namespace_store: Store<Namespace>,
70        kanidm_store: Store<Kanidm>,
71    ) -> Self {
72        Self {
73            controller_id,
74            client,
75            metrics,
76            recorder,
77            namespace_store,
78            kanidm_store,
79            idm_clients,
80            system_clients,
81            error_backoff_cache: Arc::default(),
82            client_creation_locks: Arc::default(),
83        }
84    }
85
86    pub async fn release_kanidm_clients(&self, kanidm: &Kanidm) {
87        let key = KanidmKey {
88            // safe unwrap: Kanidm is namespaced scoped
89            namespace: kube::ResourceExt::namespace(kanidm).unwrap(),
90            name: kanidm.name_any(),
91        };
92
93        self.idm_clients.write().await.remove(&key);
94        self.system_clients.write().await.remove(&key);
95        {
96            let mut locks = self.client_creation_locks.write().await;
97            locks.remove(&ClientLockKey {
98                namespace: key.namespace.clone(),
99                name: key.name.clone(),
100                user: KanidmUser::IdmAdmin,
101            });
102            locks.remove(&ClientLockKey {
103                namespace: key.namespace.clone(),
104                name: key.name.clone(),
105                user: KanidmUser::Admin,
106            });
107        }
108    }
109}
110
111impl<K> Context<K>
112where
113    K: Resource<DynamicType = ()> + ResourceExt + KanidmResource + Lookup + Clone + 'static,
114    <K as Lookup>::DynamicType: Eq + std::hash::Hash + Clone,
115{
116    /// Check if a valid client exists in cache
117    async fn get_valid_cached_client(
118        cache: &Arc<RwLock<KanidmClients>>,
119        key: &KanidmKey,
120        namespace: &str,
121        name: &str,
122    ) -> Option<Arc<KanidmClient>> {
123        let client = cache.read().await.get(key).cloned()?;
124
125        trace!(
126            msg = "check existing Kanidm client session",
127            namespace, name
128        );
129        if client.auth_valid().await.is_ok() {
130            trace!(msg = "reuse Kanidm client session", namespace, name);
131            Some(client)
132        } else {
133            None
134        }
135    }
136
137    /// Return a valid client for the Kanidm cluster. This operation require to do at least a
138    /// request for validating the client, use it wisely.
139    async fn get_kanidm_client(&self, obj: &K, user: KanidmUser) -> Result<Arc<KanidmClient>> {
140        let namespace = obj.kanidm_namespace();
141        let name = obj.kanidm_name();
142        debug!(msg = "get Kanidm client", namespace, name);
143
144        let cache = match user {
145            KanidmUser::Admin => self.system_clients.clone(),
146            KanidmUser::IdmAdmin => self.idm_clients.clone(),
147        };
148        let key = KanidmKey {
149            namespace: namespace.clone(),
150            name: name.clone(),
151        };
152        if let Some(client) = Self::get_valid_cached_client(&cache, &key, &namespace, &name).await {
153            return Ok(client);
154        }
155
156        // Slow path: acquire lock for this specific client to prevent concurrent creation
157        let creation_lock = self
158            .client_creation_locks
159            .write()
160            .await
161            .entry(ClientLockKey {
162                namespace: namespace.clone(),
163                name: name.clone(),
164                user: user.clone(),
165            })
166            .or_insert_with(Arc::default)
167            .clone();
168        let _guard = creation_lock.lock().await;
169
170        // Double-check: another task may have created the client while we waited for the lock
171        if let Some(client) = Self::get_valid_cached_client(&cache, &key, &namespace, &name).await {
172            return Ok(client);
173        }
174
175        match KanidmClients::create_client(&namespace, &name, user, self.client.clone()).await {
176            Ok(client) => {
177                cache.write().await.insert(key.clone(), client.clone());
178                Ok(client)
179            }
180            Err(e) => {
181                self.recorder
182                    .publish(
183                        &Event {
184                            type_: EventType::Warning,
185                            reason: "KanidmClientError".to_string(),
186                            note: Some(e.to_string()),
187                            action: "KanidmClientCreating".into(),
188                            secondary: None,
189                        },
190                        &obj.object_ref(&()),
191                    )
192                    .await
193                    .map_err(|e| {
194                        error!(msg = "failed to create Kanidm client", %e);
195                        Error::KubeError("failed to publish event".to_string(), Box::new(e))
196                    })?;
197                Err(e)
198            }
199        }
200    }
201
202    /// Return [`Kanidm`] of the given object
203    ///
204    /// [`Kanidm`]: struct.Kanidm.html
205    pub fn get_kanidm(&self, obj: &K) -> Option<Arc<Kanidm>> {
206        let namespace = obj.kanidm_namespace();
207        let name = obj.kanidm_name();
208        self.kanidm_store.find(|k| {
209            kube::ResourceExt::namespace(k).as_ref() == Some(&namespace) && k.name_any() == name
210        })
211    }
212}
213
214#[allow(async_fn_in_trait)]
215pub trait BackoffContext<K: Resource> {
216    fn metrics(&self) -> &Arc<ControllerMetrics>;
217    async fn get_backoff(&self, obj_ref: ObjectRef<K>) -> Duration;
218    async fn reset_backoff(&self, obj_ref: ObjectRef<K>);
219}
220
221impl<K> BackoffContext<K> for Context<K>
222where
223    K: Resource<DynamicType = ()> + ResourceExt + Lookup + Clone + 'static,
224    <K as Lookup>::DynamicType: Eq + std::hash::Hash + Clone,
225{
226    fn metrics(&self) -> &Arc<ControllerMetrics> {
227        &self.metrics
228    }
229
230    /// Return next duration of the backoff policy for the given object
231    async fn get_backoff(&self, obj_ref: ObjectRef<K>) -> Duration {
232        {
233            let read_guard = self.error_backoff_cache.read().await;
234            if let Some(backoff) = read_guard.get(&obj_ref) {
235                if let Some(duration) = backoff.write().await.next() {
236                    return duration;
237                }
238            }
239        }
240
241        // Backoff policy: 1s, 2s, 4s, 8s, 16s, 32s, 64s, 128s, 256s, 300s, 300s...
242        let mut backoff = ExponentialBuilder::default()
243            .with_max_delay(idm_reconcile_interval())
244            .without_max_times()
245            .build();
246        // First backoff is always Some(Duration), but use defensive fallback
247        let duration = backoff.next().unwrap_or_else(|| {
248            trace!("backoff returned None, using default duration");
249            Duration::from_secs(1)
250        });
251        self.error_backoff_cache
252            .write()
253            .await
254            .insert(obj_ref.clone(), RwLock::new(backoff));
255        trace!(
256            msg = format!("recreate backoff policy"),
257            namespace = obj_ref.namespace.as_deref().unwrap(),
258            name = obj_ref.name,
259        );
260        duration
261    }
262
263    /// Reset the backoff policy for the given object
264    async fn reset_backoff(&self, obj_ref: ObjectRef<K>) {
265        let read_guard = self.error_backoff_cache.read().await;
266        if read_guard.get(&obj_ref).is_some() {
267            drop(read_guard);
268            trace!(
269                msg = "reset backoff policy",
270                namespace = obj_ref.namespace.as_deref().unwrap(),
271                name = obj_ref.name
272            );
273            self.error_backoff_cache.write().await.remove(&obj_ref);
274        }
275    }
276}
277
278#[allow(async_fn_in_trait)]
279pub trait IdmClientContext<K: Resource> {
280    async fn get_idm_client(&self, obj: &K) -> Result<Arc<KanidmClient>>;
281}
282
283impl<K> IdmClientContext<K> for Context<K>
284where
285    K: Resource<DynamicType = ()> + ResourceExt + KanidmResource + Lookup + Clone + 'static,
286    <K as Lookup>::DynamicType: Eq + std::hash::Hash + Clone,
287{
288    async fn get_idm_client(&self, obj: &K) -> Result<Arc<KanidmClient>> {
289        self.get_kanidm_client(obj, KanidmUser::IdmAdmin).await
290    }
291}
292
293#[allow(async_fn_in_trait)]
294pub trait SystemClientContext<K: Resource> {
295    async fn get_system_client(&self, obj: &K) -> Result<Arc<KanidmClient>>;
296}
297
298impl<K> SystemClientContext<K> for Context<K>
299where
300    K: Resource<DynamicType = ()> + ResourceExt + KanidmResource + Lookup + Clone + 'static,
301    <K as Lookup>::DynamicType: Eq + std::hash::Hash + Clone,
302{
303    async fn get_system_client(&self, obj: &K) -> Result<Arc<KanidmClient>> {
304        self.get_kanidm_client(obj, KanidmUser::Admin).await
305    }
306}
307
308#[allow(async_fn_in_trait)]
309pub trait KubeOperations<T, K>
310where
311    T: Resource + ResourceExt + Lookup + Clone + 'static,
312    <T as Lookup>::DynamicType: Eq + std::hash::Hash + Clone,
313    K: Resource<Scope = NamespaceResourceScope>
314        + Serialize
315        + Clone
316        + std::fmt::Debug
317        + for<'de> Deserialize<'de>,
318    <K as kube::Resource>::DynamicType: Default,
319    <K as Resource>::Scope: std::marker::Sized,
320{
321    async fn kube_delete(&self, client: Client, metrics: &ControllerMetrics, obj: &K)
322    -> Result<()>;
323    async fn kube_patch(
324        &self,
325        client: Client,
326        metrics: &ControllerMetrics,
327        obj: K,
328        operator_name: &str,
329    ) -> Result<K>;
330}
331
332impl<T, K> KubeOperations<T, K> for T
333where
334    T: Resource + ResourceExt + Lookup + Clone + 'static,
335    <T as Lookup>::DynamicType: Eq + std::hash::Hash + Clone,
336    K: Resource<Scope = NamespaceResourceScope>
337        + Serialize
338        + Clone
339        + std::fmt::Debug
340        + for<'de> Deserialize<'de>,
341    <K as kube::Resource>::DynamicType: Default,
342    <K as Resource>::Scope: std::marker::Sized,
343{
344    async fn kube_delete(
345        &self,
346        client: Client,
347        _metrics: &ControllerMetrics,
348        obj: &K,
349    ) -> Result<()> {
350        let name = obj.name_any();
351        // safe unwrap: self is namespaced scoped
352        let namespace = kube::ResourceExt::namespace(self).unwrap();
353        trace!(
354            msg = format!("deleting {}", short_type_name::<K>().unwrap_or("Unknown")),
355            resource.name = &name,
356            resource.namespace = &namespace
357        );
358        let api = Api::<K>::namespaced(client, &namespace);
359        api.delete(&name, &Default::default()).await.map_err(|e| {
360            Error::KubeError(
361                format!(
362                    "failed to delete {} {namespace}/{name}",
363                    short_type_name::<K>().unwrap_or("Unknown")
364                ),
365                Box::new(e),
366            )
367        })?;
368        Ok(())
369    }
370
371    async fn kube_patch(
372        &self,
373        client: Client,
374        metrics: &ControllerMetrics,
375        obj: K,
376        operator_name: &str,
377    ) -> Result<K> {
378        let name = obj.name_any();
379        // safe unwrap: self is namespaced scoped
380        let namespace = kube::ResourceExt::namespace(self).unwrap();
381        trace!(
382            msg = format!("patching {}", short_type_name::<K>().unwrap_or("Unknown")),
383            resource.name = &name,
384            resource.namespace = &namespace
385        );
386        let resource_api = Api::<K>::namespaced(client.clone(), &namespace);
387
388        let result = resource_api
389            .patch(
390                &name,
391                &PatchParams::apply(operator_name).force(),
392                &Patch::Apply(&obj),
393            )
394            .await;
395        match result {
396            Ok(resource) => Ok(resource),
397            Err(e) => match e {
398                kube::Error::Api(ae) if ae.code == 422 => {
399                    info!(
400                        msg = format!(
401                            "recreating {} because the update operation was not possible",
402                            short_type_name::<K>().unwrap_or("Unknown")
403                        ),
404                        reason = ae.reason
405                    );
406                    trace!(msg = "operation was not possible because of 422", ?ae);
407                    self.kube_delete(client.clone(), metrics, &obj).await?;
408                    metrics.reconcile_deploy_delete_create_inc();
409                    resource_api
410                        .patch(
411                            &name,
412                            &PatchParams::apply(operator_name).force(),
413                            &Patch::Apply(&obj),
414                        )
415                        .await
416                        .map_err(|e| {
417                            Error::KubeError(
418                                format!(
419                                    "failed to re-try patch {} {namespace}/{name}",
420                                    short_type_name::<K>().unwrap_or("Unknown")
421                                ),
422                                Box::new(e),
423                            )
424                        })
425                }
426                _ => Err(Error::KubeError(
427                    format!(
428                        "failed to patch {} {namespace}/{name}",
429                        short_type_name::<K>().unwrap_or("Unknown")
430                    ),
431                    Box::new(e),
432                )),
433            },
434        }
435    }
436}