Skip to main content

kaniop_operator/controller/
mod.rs

1pub mod context;
2pub mod kanidm;
3
4use self::{context::Context, kanidm::KanidmClients};
5
6use crate::kanidm::crd::Kanidm;
7use crate::metrics;
8use crate::prometheus_exporter;
9
10use kaniop_k8s_util::error::{Error, Result};
11
12use kaniop_k8s_util::types::short_type_name;
13
14use std::fmt::Debug;
15use std::sync::Arc;
16
17use futures::channel::mpsc;
18use futures::future::BoxFuture;
19use futures::{FutureExt, StreamExt};
20use k8s_openapi::api::core::v1::Namespace;
21use kube::Resource;
22use kube::api::{Api, ListParams, PartialObjectMeta, ResourceExt};
23use kube::client::Client;
24use kube::runtime::controller::Action;
25use kube::runtime::events::Recorder;
26use kube::runtime::reflector::store::Writer;
27use kube::runtime::reflector::{self, Lookup, ReflectHandle, Store};
28use kube::runtime::{WatchStreamExt, metadata_watcher, watcher};
29use serde::de::DeserializeOwned;
30use tokio::sync::RwLock;
31use tokio::time::Duration;
32use tracing::{debug, error, info, trace};
33
34use std::sync::OnceLock;
35
36const DEFAULT_IDM_RECONCILE_INTERVAL_SECS: u64 = 60;
37const DEFAULT_CLUSTER_DOMAIN: &str = "cluster.local";
38
39static IDM_RECONCILE_INTERVAL: OnceLock<Duration> = OnceLock::new();
40static CLUSTER_DOMAIN: OnceLock<String> = OnceLock::new();
41
42pub fn set_idm_reconcile_interval(duration: Duration) {
43    let _ = IDM_RECONCILE_INTERVAL.set(duration);
44}
45
46pub fn idm_reconcile_interval() -> Duration {
47    *IDM_RECONCILE_INTERVAL.get_or_init(|| Duration::from_secs(DEFAULT_IDM_RECONCILE_INTERVAL_SECS))
48}
49
50pub fn set_cluster_domain(domain: String) {
51    let _ = CLUSTER_DOMAIN.set(domain);
52}
53
54pub fn cluster_domain() -> &'static str {
55    CLUSTER_DOMAIN.get_or_init(|| DEFAULT_CLUSTER_DOMAIN.to_string())
56}
57pub const SUBSCRIBE_BUFFER_SIZE: usize = 256;
58pub const RELOAD_BUFFER_SIZE: usize = 16;
59pub const NAME_LABEL: &str = "app.kubernetes.io/name";
60pub const INSTANCE_LABEL: &str = "app.kubernetes.io/instance";
61pub const MANAGED_BY_LABEL: &str = "app.kubernetes.io/managed-by";
62
63pub type ControllerId = &'static str;
64
65/// State shared between the controller and the web server
66// Kanidm defined as a generic because it causes a cycle dependency with the kaniop_kanidm crate
67#[derive(Clone)]
68pub struct State {
69    /// Metrics
70    metrics: Arc<metrics::Metrics>,
71    /// Shared Kanidm cache clients with the ability to manage users and their groups
72    idm_clients: Arc<RwLock<KanidmClients>>,
73    /// Shared Kanidm cache clients with the ability to manage the operation of Kanidm as a
74    /// database and service
75    system_clients: Arc<RwLock<KanidmClients>>,
76    /// Cache for Namespace resources
77    pub namespace_store: Store<Namespace>,
78    /// Cache for Kanidm resources
79    pub kanidm_store: Store<Kanidm>,
80    /// Kubernetes client
81    pub client: Option<Client>,
82}
83
84/// Shared state for a resource stream
85pub struct ResourceReflector<K>
86where
87    K: Resource + Lookup + Clone + 'static,
88    <K as Lookup>::DynamicType: Eq + std::hash::Hash + Clone,
89{
90    pub store: Store<K>,
91    pub writer: Writer<K>,
92    pub subscriber: ReflectHandle<K>,
93}
94
95/// State wrapper around the controller outputs for the web server
96impl State {
97    pub fn new(
98        metrics: metrics::Metrics,
99        namespace_store: Store<Namespace>,
100        kanidm_store: Store<Kanidm>,
101        client: Option<Client>,
102    ) -> Self {
103        Self {
104            metrics: Arc::new(metrics),
105            idm_clients: Arc::default(),
106            system_clients: Arc::default(),
107            namespace_store,
108            kanidm_store,
109            client,
110        }
111    }
112
113    /// Metrics getter
114    pub fn metrics(&self) -> Result<String> {
115        prometheus_exporter::format_prometheus_metrics("kaniop").map_err(|e| {
116            Error::FormattingError(format!("failed to export metrics: {}", e), std::fmt::Error)
117        })
118    }
119
120    /// Create a Controller Context that can update State
121    pub fn to_context<K>(&self, client: Client, controller_id: ControllerId) -> Context<K>
122    where
123        K: Resource + Lookup + Clone + 'static,
124        <K as Lookup>::DynamicType: Default + Eq + std::hash::Hash + Clone,
125    {
126        Context::new(
127            controller_id,
128            client.clone(),
129            self.metrics
130                .controllers
131                .get(controller_id)
132                .expect("all CONTROLLER_IDs have to be registered")
133                .clone(),
134            Recorder::new(client.clone(), controller_id.into()),
135            self.idm_clients.clone(),
136            self.system_clients.clone(),
137            self.namespace_store.clone(),
138            self.kanidm_store.clone(),
139        )
140    }
141}
142
143pub async fn check_api_queryable<K>(client: Client) -> Api<K>
144where
145    K: Resource + Clone + DeserializeOwned + Debug,
146    <K as Resource>::DynamicType: Default,
147{
148    let api = Api::<K>::all(client.clone());
149    if let Err(e) = api.list(&ListParams::default().limit(1)).await {
150        error!(
151            "{} is not queryable; {e:?}. Check controller permissions",
152            short_type_name::<K>().unwrap_or("Unknown resource"),
153        );
154        std::process::exit(1);
155    }
156    api
157}
158
159pub async fn check_api_queryable_optional<K>(client: Client) -> Option<Api<K>>
160where
161    K: Resource + Clone + DeserializeOwned + Debug,
162    <K as Resource>::DynamicType: Default,
163{
164    let api = Api::<K>::all(client.clone());
165    if let Err(e) = api.list(&ListParams::default().limit(1)).await {
166        info!(
167            "{} is not queryable (optional resource); {e:?}. Skipping optional resource support.",
168            short_type_name::<K>().unwrap_or("Unknown resource"),
169        );
170        None
171    } else {
172        Some(api)
173    }
174}
175
176pub fn create_subscriber<K>(buffer_size: usize) -> ResourceReflector<K>
177where
178    K: Resource + Lookup + Clone + 'static,
179    <K as Lookup>::DynamicType: Default + Eq + std::hash::Hash + Clone,
180    <K as Resource>::DynamicType: Default + Eq + std::hash::Hash + Clone,
181{
182    let (store, writer) = reflector::store_shared::<K>(buffer_size);
183    let subscriber = writer
184        .subscribe()
185        .expect("subscribers can only be created from shared stores");
186
187    ResourceReflector {
188        store,
189        writer,
190        subscriber,
191    }
192}
193
194fn create_generic_watcher<K, W, T, S, StreamT>(
195    api: Api<K>,
196    writer: Writer<W>,
197    reload_tx: mpsc::Sender<()>,
198    controller_id: ControllerId,
199    ctx: Arc<Context<T>>,
200    stream_fn: S,
201) -> BoxFuture<'static, ()>
202where
203    K: Resource + Lookup + Clone + DeserializeOwned + Send + Sync + Debug + 'static,
204    W: Resource + ResourceExt + Lookup + Clone + Debug + Send + Sync + 'static,
205    <W as Resource>::DynamicType: Eq + std::hash::Hash + Clone + Send + Sync,
206    <K as Lookup>::DynamicType: Default + Eq + std::hash::Hash + Clone + Send + Sync,
207    <K as Resource>::DynamicType: Default + Eq + std::hash::Hash + Clone,
208    <W as Lookup>::DynamicType: Eq + std::hash::Hash + Clone + Send + Sync,
209    T: Resource<DynamicType = ()> + ResourceExt + Lookup + Clone + 'static,
210    <T as Lookup>::DynamicType: Eq + std::hash::Hash + Clone + Send + Sync,
211    S: Fn(Api<K>, watcher::Config) -> StreamT + 'static,
212    StreamT: futures::Stream<Item = Result<watcher::Event<W>, kube::runtime::watcher::Error>>
213        + Send
214        + 'static,
215{
216    let resource_name = short_type_name::<K>().unwrap_or("Unknown");
217
218    stream_fn(
219        api,
220        watcher::Config::default().labels(&format!("{MANAGED_BY_LABEL}=kaniop-{controller_id}")),
221    )
222    .default_backoff()
223    .reflect_shared(writer)
224    .for_each(move |res| {
225        let mut reload_tx_clone = reload_tx.clone();
226        let ctx = ctx.clone();
227        async move {
228            match res {
229                Ok(event) => {
230                    trace!(msg = "watched event", ?event);
231                    match event {
232                        watcher::Event::Delete(d) => {
233                            debug!(
234                                msg = format!("delete event for {resource_name} trigger reconcile"),
235                                namespace = ResourceExt::namespace(&d).unwrap(),
236                                name = d.name_any()
237                            );
238
239                            // TODO: remove for each trigger on delete logic when
240                            // (dispatch delete events issue)[https://github.com/kube-rs/kube/issues/1590]
241                            // is solved
242                            let _ignore_errors = reload_tx_clone.try_send(()).map_err(
243                                |e| error!(msg = "failed to trigger reconcile on delete", %e),
244                            );
245                            ctx.metrics
246                                .triggered_inc(metrics::Action::Delete, resource_name);
247                        }
248                        watcher::Event::Apply(d) => {
249                            debug!(
250                                msg = format!("apply event for {resource_name} trigger reconcile"),
251                                namespace = ResourceExt::namespace(&d).unwrap(),
252                                name = d.name_any()
253                            );
254                            ctx.metrics
255                                .triggered_inc(metrics::Action::Apply, resource_name);
256                        }
257                        _ => {}
258                    }
259                }
260                Err(e) => {
261                    error!(msg = format!("unexpected error when watching {resource_name}"), %e);
262                    ctx.metrics.watch_operations_failed_inc();
263                }
264            }
265        }
266    })
267    .boxed()
268}
269
270pub fn create_watcher<K, T>(
271    api: Api<K>,
272    writer: Writer<K>,
273    reload_tx: mpsc::Sender<()>,
274    controller_id: ControllerId,
275    ctx: Arc<Context<T>>,
276) -> BoxFuture<'static, ()>
277where
278    K: Resource + Lookup + Clone + DeserializeOwned + Send + Sync + Debug + 'static,
279    <K as Lookup>::DynamicType: Default + Eq + std::hash::Hash + Clone + Send + Sync,
280    <K as Resource>::DynamicType: Default + Eq + std::hash::Hash + Clone,
281    T: Resource<DynamicType = ()> + ResourceExt + Lookup + Clone + 'static,
282    <T as Lookup>::DynamicType: Eq + std::hash::Hash + Clone + Send + Sync,
283{
284    create_generic_watcher::<K, K, T, _, _>(api, writer, reload_tx, controller_id, ctx, watcher)
285}
286
287pub fn create_metadata_watcher<K, T>(
288    api: Api<K>,
289    writer: Writer<PartialObjectMeta<K>>,
290    reload_tx: mpsc::Sender<()>,
291    controller_id: ControllerId,
292    ctx: Arc<Context<T>>,
293) -> BoxFuture<'static, ()>
294where
295    K: Resource + Lookup + Clone + DeserializeOwned + Send + Sync + Debug + 'static,
296    <K as Lookup>::DynamicType: Default + Eq + std::hash::Hash + Clone + Send + Sync,
297    <K as Resource>::DynamicType: Default + Eq + std::hash::Hash + Clone,
298    T: Resource<DynamicType = ()> + ResourceExt + Lookup + Clone + 'static,
299    <T as Lookup>::DynamicType: Eq + std::hash::Hash + Clone + Send + Sync,
300{
301    create_generic_watcher::<K, PartialObjectMeta<K>, T, _, _>(
302        api,
303        writer,
304        reload_tx,
305        controller_id,
306        ctx,
307        metadata_watcher,
308    )
309}
310
311pub fn error_policy<K>(_obj: Arc<K>, _error: &Error, _ctx: Arc<Context<K>>) -> Action
312where
313    K: Resource + Lookup + Clone + 'static,
314    <K as Lookup>::DynamicType: Default + Eq + std::hash::Hash + Clone,
315{
316    unreachable!("Handle in backoff_reconciler macro")
317}
318
319#[macro_export]
320macro_rules! backoff_reconciler {
321    ($inner_reconciler:ident) => {
322        |obj, ctx| async move {
323            use $crate::controller::context::BackoffContext;
324            match $inner_reconciler(obj.clone(), ctx.clone()).await {
325                Ok(action) => {
326                    ctx.reset_backoff(kube::runtime::reflector::ObjectRef::from(obj.as_ref()))
327                        .await;
328                    Ok(action)
329                }
330                Err(error) => {
331                    // safe unwrap: all resources in the operator are namespace scoped resources
332                    let namespace = kube::ResourceExt::namespace(obj.as_ref()).unwrap();
333                    let name = kube::ResourceExt::name_any(obj.as_ref());
334                    tracing::error!(msg = "failed reconciliation", %namespace, %name, %error);
335                    ctx.metrics().reconcile_failure_inc();
336                    let backoff_duration = ctx
337                        .get_backoff(kube::runtime::reflector::ObjectRef::from(obj.as_ref()))
338                        .await;
339                    tracing::trace!(
340                        msg = format!("backoff duration: {backoff_duration:?}"),
341                        %namespace,
342                        %name,
343                    );
344                    Ok(kube::runtime::controller::Action::requeue(backoff_duration))
345                }
346            }
347        }
348    };
349}