Skip to main content

k8s_controller/
controller.rs

1use std::collections::BTreeMap;
2use std::error::Error as _;
3use std::sync::{Arc, Mutex};
4use std::time::{Duration, Instant};
5
6use futures::future::FutureExt;
7use futures::stream::StreamExt;
8use kube::api::Api;
9use kube::core::{ClusterResourceScope, NamespaceResourceScope};
10use kube::{Client, Resource, ResourceExt};
11use kube_runtime::controller::Action;
12use kube_runtime::finalizer::{Event as FinalizerEvent, finalizer};
13use kube_runtime::watcher;
14use rand::{RngExt, rng};
15use tracing::field::Empty;
16use tracing::{Instrument, Span, error, info, info_span, trace, warn};
17
18use crate::events::{Event, EventRecorder, EventType};
19use crate::observe::{Outcome, Pass, Phase, ReconcileObserver, TraceMetadata};
20
21/// An error from a reconciliation pass, as passed to
22/// [`Context::error_action`] and [`Context::failure_event`].
23#[derive(Debug, thiserror::Error)]
24pub enum Error<E: std::error::Error + 'static> {
25    /// [`Context::apply`] returned an error, and the context has no
26    /// finalizer.
27    #[error("{0}")]
28    ControllerError(#[source] E),
29    /// The context has a finalizer, and either [`Context::apply`] or
30    /// [`Context::cleanup`] returned an error (wrapped as
31    /// [`ApplyFailed`](kube_runtime::finalizer::Error::ApplyFailed) or
32    /// [`CleanupFailed`](kube_runtime::finalizer::Error::CleanupFailed)), or
33    /// adding or removing the finalizer failed.
34    #[error("{0}")]
35    FinalizerError(#[source] kube_runtime::finalizer::Error<E>),
36}
37
38impl<E: std::error::Error + 'static> Error<E> {
39    /// Renders this error followed by each of its causes, separated by
40    /// `": "`, as used in the note of the default
41    /// [failure event](Context::failure_event).
42    pub fn display_chain(&self) -> String {
43        let mut out = self.to_string();
44        let mut source = self.source();
45        while let Some(err) = source {
46            let message = err.to_string();
47            // Error types commonly end their own message with their source's
48            // (as this one does), which a naive join would repeat.
49            if !out.ends_with(&message) {
50                out.push_str(": ");
51                out.push_str(&message);
52            }
53            source = err.source();
54        }
55        out
56    }
57
58    /// Whether the API server rejected a write because the object changed
59    /// since the controller read it. Retrying against the newer version
60    /// resolves this on its own.
61    ///
62    /// This covers a `409 Conflict` anywhere in the source chain, for
63    /// instance from a replace with a stale `resourceVersion`, and a failed
64    /// `test` operation in the JSON patch that adds or removes the finalizer.
65    pub fn is_conflict(&self) -> bool {
66        if let Error::FinalizerError(
67            kube_runtime::finalizer::Error::AddFinalizer(e)
68            | kube_runtime::finalizer::Error::RemoveFinalizer(e),
69        ) = self
70            && is_failed_patch_test(e)
71        {
72            return true;
73        }
74        let mut source: Option<&(dyn std::error::Error + 'static)> = Some(self);
75        while let Some(err) = source {
76            if let Some(kube::Error::Api(status)) = err.downcast_ref::<kube::Error>()
77                && status.code == 409
78                && status.reason == "Conflict"
79            {
80                return true;
81            }
82            source = err.source();
83        }
84        false
85    }
86}
87
88/// Whether `e` is the API server rejecting a JSON patch whose `test`
89/// operation failed.
90fn is_failed_patch_test(e: &kube::Error) -> bool {
91    // Observed: a failed `test` is a 422 whose details name no object kind
92    // and list no causes. A validation failure, which retrying does not fix,
93    // is also a 422 but names the kind and the invalid fields.
94    matches!(
95        e,
96        kube::Error::Api(status)
97            if status.code == 422
98                && status
99                    .details
100                    .as_ref()
101                    .is_none_or(|d| d.kind.is_empty() && d.causes.is_empty())
102    )
103}
104
105/// The observability configured on a [`Controller`], shared by its passes.
106struct Instrumentation {
107    name: Arc<str>,
108    observer: Option<Arc<dyn ReconcileObserver>>,
109    events: Option<Arc<EventRecorder>>,
110}
111
112/// The [`Controller`] watches a set of resources, calling methods on the
113/// provided [`Context`] when events occur.
114pub struct Controller<Ctx: Context>
115where
116    Ctx: Send + Sync + 'static,
117    Ctx::Error: Send + Sync + 'static,
118    Ctx::Resource: Send + Sync + 'static,
119    Ctx::Resource: Clone + std::fmt::Debug + serde::Serialize,
120    for<'de> Ctx::Resource: serde::Deserialize<'de>,
121    <Ctx::Resource as Resource>::DynamicType:
122        Eq + Clone + std::hash::Hash + std::default::Default + std::fmt::Debug + std::marker::Unpin,
123{
124    client: kube::Client,
125    make_api: Box<dyn Fn(&Ctx::Resource) -> Api<Ctx::Resource> + Sync + Send + 'static>,
126    controller: kube_runtime::controller::Controller<Ctx::Resource>,
127    context: Ctx,
128    name: Option<Arc<str>>,
129    observer: Option<Arc<dyn ReconcileObserver>>,
130    events: Option<Arc<EventRecorder>>,
131}
132
133impl<Ctx: Context> Controller<Ctx>
134where
135    Ctx: Send + Sync + 'static,
136    Ctx::Error: Send + Sync + 'static,
137    Ctx::Resource: Clone + std::fmt::Debug + serde::Serialize,
138    for<'de> Ctx::Resource: serde::Deserialize<'de>,
139    <Ctx::Resource as Resource>::DynamicType:
140        Eq + Clone + std::hash::Hash + std::default::Default + std::fmt::Debug + std::marker::Unpin,
141{
142    /// Creates a new controller for a namespaced resource using the given
143    /// `client`. The `context` given determines the type of resource
144    /// to watch (via the [`Context::Resource`] type provided as part of
145    /// the trait implementation). The resources to be watched will be
146    /// limited to resources in the given `namespace`. A [`watcher::Config`]
147    /// can be given to limit the resources watched (for instance,
148    /// `watcher::Config::default().labels("app=myapp")`).
149    pub fn namespaced(client: Client, context: Ctx, namespace: &str, wc: watcher::Config) -> Self
150    where
151        Ctx::Resource: Resource<Scope = NamespaceResourceScope>,
152    {
153        let make_api = {
154            let client = client.clone();
155            Box::new(move |resource: &Ctx::Resource| {
156                Api::<Ctx::Resource>::namespaced(client.clone(), &resource.namespace().unwrap())
157            })
158        };
159        let controller = kube_runtime::controller::Controller::new(
160            Api::<Ctx::Resource>::namespaced(client.clone(), namespace),
161            wc,
162        );
163        Self::new(client, make_api, controller, context)
164    }
165
166    /// Creates a new controller for a namespaced resource using the given
167    /// `client`. The `context` given determines the type of resource to
168    /// watch (via the [`Context::Resource`] type provided as part of the
169    /// trait implementation). The resources to be watched will not be
170    /// limited by namespace. A [`watcher::Config`] can be given to limit the
171    /// resources watched (for instance,
172    /// `watcher::Config::default().labels("app=myapp")`).
173    pub fn namespaced_all(client: Client, context: Ctx, wc: watcher::Config) -> Self
174    where
175        Ctx::Resource: Resource<Scope = NamespaceResourceScope>,
176    {
177        let make_api = {
178            let client = client.clone();
179            Box::new(move |resource: &Ctx::Resource| {
180                Api::<Ctx::Resource>::namespaced(client.clone(), &resource.namespace().unwrap())
181            })
182        };
183        let controller = kube_runtime::controller::Controller::new(
184            Api::<Ctx::Resource>::all(client.clone()),
185            wc,
186        );
187        Self::new(client, make_api, controller, context)
188    }
189
190    /// Creates a new controller for a cluster-scoped resource using the
191    /// given `client`. The `context` given determines the type of resource
192    /// to watch (via the [`Context::Resource`] type provided as part of the
193    /// trait implementation). A [`watcher::Config`] can be given to limit the
194    /// resources watched (for instance,
195    /// `watcher::Config::default().labels("app=myapp")`).
196    pub fn cluster(client: Client, context: Ctx, wc: watcher::Config) -> Self
197    where
198        Ctx::Resource: Resource<Scope = ClusterResourceScope>,
199    {
200        let make_api = {
201            let client = client.clone();
202            Box::new(move |_: &Ctx::Resource| Api::<Ctx::Resource>::all(client.clone()))
203        };
204        let controller = kube_runtime::controller::Controller::new(
205            Api::<Ctx::Resource>::all(client.clone()),
206            wc,
207        );
208        Self::new(client, make_api, controller, context)
209    }
210
211    fn new(
212        client: Client,
213        make_api: Box<dyn Fn(&Ctx::Resource) -> Api<Ctx::Resource> + Sync + Send + 'static>,
214        controller: kube_runtime::controller::Controller<Ctx::Resource>,
215        context: Ctx,
216    ) -> Self {
217        Self {
218            client,
219            make_api,
220            controller,
221            context,
222            name: None,
223            observer: None,
224            events: None,
225        }
226    }
227
228    /// Sets the name identifying this controller in its metrics (the
229    /// `controller` field of [`ReconcileRecord`](crate::ReconcileRecord) and
230    /// [`StepRecord`](crate::StepRecord)), and in the `controller` field of
231    /// its `reconcile` tracing span. Defaults to [`Context::FINALIZER_NAME`]
232    /// if set, and otherwise to the kind of the resource being watched.
233    ///
234    /// Controllers sharing an [observer](Controller::with_observer) must
235    /// have distinct names, or their metrics will be merged.
236    pub fn with_name(mut self, name: impl Into<String>) -> Self {
237        self.name = Some(name.into().into());
238        self
239    }
240
241    /// Reports every reconciliation pass, and every [step](crate::Step)
242    /// within one, to `observer`. See the [`observe`](crate::observe)
243    /// module.
244    pub fn with_observer(mut self, observer: Arc<dyn ReconcileObserver>) -> Self {
245        self.observer = Some(observer);
246        self
247    }
248
249    /// Publishes a Kubernetes event on the resource whenever reconciling it
250    /// fails, as determined by [`Context::failure_event`], and enables
251    /// [`TraceMetadata::publish_event`] for the context's own events. See
252    /// the [`events`](crate::events) module, including for the RBAC
253    /// permissions this requires.
254    ///
255    /// `events` must not be given to any other controller. Besides every
256    /// event appearing to come from the same controller, a successful pass
257    /// of one controller would reset the aggregation of another's failure
258    /// events for the same resource.
259    pub fn with_event_recorder(mut self, events: Arc<EventRecorder>) -> Self {
260        self.events = Some(events);
261        self
262    }
263
264    /// Run the controller. This method will not return. The [`Context`]
265    /// given to the constructor will have its [`apply`](Context::apply)
266    /// method called when a resource is created or updated, and its
267    /// [`cleanup`](Context::cleanup) method called when a resource is about
268    /// to be deleted.
269    ///
270    /// To run multiple replicas of a controller with only one reconciling
271    /// at a time, pass this method's future to
272    /// [`LeaderElection::with_lease`](crate::LeaderElection::with_lease).
273    pub async fn run(self) {
274        let Self {
275            client,
276            make_api,
277            controller,
278            context,
279            name,
280            observer,
281            events,
282        } = self;
283        let instrumentation = Arc::new(Instrumentation {
284            name: name.unwrap_or_else(|| match Ctx::FINALIZER_NAME {
285                Some(finalizer_name) => finalizer_name.into(),
286                None => Ctx::Resource::kind(&Default::default()).into(),
287            }),
288            observer,
289            events,
290        });
291        let instrumentation = &instrumentation;
292        let backoffs = Arc::new(Mutex::new(BTreeMap::new()));
293        let backoffs = &backoffs;
294        controller
295            .run(
296                |resource, context| {
297                    let uid = resource.uid().unwrap();
298                    let backoffs = Arc::clone(backoffs);
299                    reconcile(
300                        context,
301                        client.clone(),
302                        make_api(&resource),
303                        resource,
304                        Arc::clone(instrumentation),
305                    )
306                    .inspect(move |result| {
307                        if result.is_ok() {
308                            backoffs.lock().unwrap().remove(&uid);
309                        }
310                    })
311                },
312                |resource, err, context| {
313                    let consecutive_errors = {
314                        let uid = resource.uid().unwrap();
315                        let mut backoffs = backoffs.lock().unwrap();
316                        let consecutive_errors: u32 =
317                            backoffs.get(&uid).copied().unwrap_or_default();
318                        backoffs.insert(uid, consecutive_errors.saturating_add(1));
319                        consecutive_errors
320                    };
321                    context.error_action(resource, err, consecutive_errors)
322                },
323                Arc::new(context),
324            )
325            .for_each(|res| async {
326                // ReconcilerFailed errors will already have been reported by
327                // the _reconcile function
328                if let Err(e) = res
329                    && !matches!(e, kube_runtime::controller::Error::ReconcilerFailed(..))
330                {
331                    // warn instead of error because these kinds of errors
332                    // are almost always recoverable
333                    warn!(
334                        error = %e,
335                        source = e.source(),
336                        "internal kube controller error",
337                    );
338                }
339            })
340            .await
341    }
342
343    /// Allow configuring the underlying [`kube_runtime::Controller`]. For
344    /// example, you can use
345    /// `controller.with_controller(|controller| controller.with_config(Config::default().concurrency(10)))`
346    /// to limit the created controller to reconciling 10 resources at once.
347    pub fn with_controller<F>(mut self, f: F) -> Self
348    where
349        F: FnOnce(
350            kube_runtime::Controller<Ctx::Resource>,
351        ) -> kube_runtime::Controller<Ctx::Resource>,
352    {
353        self.controller = f(self.controller);
354        self
355    }
356}
357
358/// The [`Context`] trait should be implemented in order to provide callbacks
359/// for events that happen to resources watched by a [`Controller`].
360#[cfg_attr(not(docsrs), async_trait::async_trait)]
361pub trait Context {
362    /// The type of Kubernetes [resource](Resource) that will be watched by
363    /// the [`Controller`] this context is passed to
364    type Resource: Resource + Send + Sync + 'static;
365    /// The error type which will be returned by the [`apply`](Self::apply)
366    /// and [`cleanup`](Self::cleanup) methods
367    type Error: std::error::Error;
368
369    /// The name to use for the finalizer. This must be unique across
370    /// controllers - if multiple controllers with the same finalizer name
371    /// run against the same resource, unexpected behavior can occur.
372    ///
373    /// If this is None (the default), a finalizer will not be used, and
374    /// cleanup events will not be reported.
375    const FINALIZER_NAME: Option<&'static str> = None;
376
377    /// This method is called when a watched resource is created or updated.
378    /// The [`Client`] used by the controller is passed in to allow making
379    /// additional API requests, as is the resource which triggered this
380    /// event. If this method returns `Some(action)`, the given action will
381    /// be performed, otherwise if `None` is returned,
382    /// [`success_action`](Self::success_action) will be called to find the
383    /// action to perform.
384    ///
385    /// `metadata` is state for this reconciliation pass, which can be used
386    /// to annotate its tracing span, time [steps](TraceMetadata::step) of
387    /// the work for metrics, and [publish events](TraceMetadata::publish_event)
388    /// about the resource.
389    ///
390    /// What this returns determines the [`Outcome`] reported to the
391    /// controller's [observer](Controller::with_observer), as described in
392    /// [`Outcome::of_result`].
393    async fn apply(
394        &self,
395        client: Client,
396        resource: &Self::Resource,
397        metadata: &mut TraceMetadata,
398    ) -> Result<Option<Action>, Self::Error>;
399
400    /// This method is called when a watched resource is marked for deletion.
401    /// The [`Client`] used by the controller is passed in to allow making
402    /// additional API requests, as is the resource which triggered this
403    /// event. If this method returns `Some(action)`, the given action will
404    /// be performed, otherwise if `None` is returned,
405    /// [`success_action`](Self::success_action) will be called to find the
406    /// action to perform.
407    ///
408    /// `metadata` and the return value are treated as for
409    /// [`apply`](Self::apply).
410    ///
411    /// Note that this method will only be called if a finalizer is used.
412    async fn cleanup(
413        &self,
414        client: Client,
415        resource: &Self::Resource,
416        metadata: &mut TraceMetadata,
417    ) -> Result<Option<Action>, Self::Error> {
418        // use a better name for the parameter name in the docs
419        let _client = client;
420        let _resource = resource;
421        let _metadata = metadata;
422
423        Ok(Some(Action::await_change()))
424    }
425
426    /// This method is called when a call to [`apply`](Self::apply) or
427    /// [`cleanup`](Self::cleanup) returns `Ok(None)`. It should return the
428    /// default [`Action`] to perform. The default implementation will
429    /// requeue the event at a random time between 40 and 60 minutes in the
430    /// future.
431    fn success_action(&self, resource: &Self::Resource) -> Action {
432        // use a better name for the parameter name in the docs
433        let _resource = resource;
434
435        Action::requeue(Duration::from_secs(rng().random_range(2400..3600)))
436    }
437
438    /// This method is called when a call to [`apply`](Self::apply) or
439    /// [`cleanup`](Self::cleanup) returns `Err`. It should return the
440    /// default [`Action`] to perform. The error returned will be passed in
441    /// here, as well as a count of how many consecutive errors have happened
442    /// for this resource, to allow for an exponential backoff strategy. The
443    /// default implementation uses exponential backoff with a max of 256
444    /// seconds and some added randomization to avoid thundering herds.
445    fn error_action(
446        self: Arc<Self>,
447        resource: Arc<Self::Resource>,
448        err: &Error<Self::Error>,
449        consecutive_errors: u32,
450    ) -> Action {
451        // use a better name for the parameter name in the docs
452        let _resource = resource;
453        let _err = err;
454
455        let seconds = 2u64.pow(consecutive_errors.min(7) + 1);
456        Action::requeue(Duration::from_millis(
457            rng().random_range((seconds * 500)..(seconds * 1000)),
458        ))
459    }
460
461    /// This method is called when a reconciliation pass fails, if the
462    /// controller has an [event recorder](Controller::with_event_recorder),
463    /// to determine the Kubernetes event to publish on the resource. Return
464    /// `None` to publish nothing, for instance for errors that are an
465    /// expected part of waiting on something else.
466    ///
467    /// The default implementation publishes a `Warning` event with reason
468    /// `ReconcileFailed` (or `CleanupFailed`, if the resource is being
469    /// deleted), and the error and its causes as the note. Repeats of an
470    /// identical event are aggregated, as described in the
471    /// [`events`](crate::events) module; this means that errors which
472    /// include something that varies from one attempt to the next (such as a
473    /// timestamp or request ID) create a new event per attempt, so should be
474    /// rephrased here.
475    fn failure_event(
476        &self,
477        resource: &Self::Resource,
478        phase: Phase,
479        err: &Error<Self::Error>,
480    ) -> Option<Event> {
481        // use a better name for the parameter name in the docs
482        let _resource = resource;
483
484        let (reason, action) = match phase {
485            Phase::Cleanup | Phase::Delete => ("CleanupFailed", "Cleanup"),
486            _ => ("ReconcileFailed", "Reconcile"),
487        };
488        Some(Event {
489            type_: EventType::Warning,
490            reason: reason.to_owned(),
491            action: action.to_owned(),
492            note: Some(err.display_chain()),
493            related: None,
494        })
495    }
496}
497
498/// Records a reconciliation pass as [abandoned](Outcome::Abandoned) if it is
499/// dropped before being finished.
500struct PassGuard {
501    pass: Arc<Pass>,
502    start: Instant,
503    phase: Option<Phase>,
504    fallback_phase: Phase,
505    finished: bool,
506}
507
508impl PassGuard {
509    fn phase(&self) -> Phase {
510        self.phase.unwrap_or(self.fallback_phase)
511    }
512
513    fn finish(&mut self, outcome: Outcome) {
514        self.finished = true;
515        self.pass
516            .record(self.phase(), outcome, self.start.elapsed());
517    }
518}
519
520impl Drop for PassGuard {
521    fn drop(&mut self) {
522        if !self.finished {
523            self.pass
524                .record(self.phase(), Outcome::Abandoned, self.start.elapsed());
525        }
526    }
527}
528
529async fn reconcile<Ctx>(
530    ctx: Arc<Ctx>,
531    client: Client,
532    api: Api<Ctx::Resource>,
533    resource: Arc<Ctx::Resource>,
534    instrumentation: Arc<Instrumentation>,
535) -> Result<Action, Error<Ctx::Error>>
536where
537    Ctx: Context + Send + Sync + 'static,
538    Ctx::Error: Send + Sync + 'static,
539    Ctx::Resource: Send + Sync + 'static,
540    Ctx::Resource: Clone + std::fmt::Debug + serde::Serialize,
541    for<'de> Ctx::Resource: serde::Deserialize<'de>,
542    <Ctx::Resource as Resource>::DynamicType:
543        Eq + Clone + std::hash::Hash + std::default::Default + std::fmt::Debug + std::marker::Unpin,
544{
545    let span = info_span!(
546        "reconcile",
547        resource_type = Ctx::Resource::kind(&Default::default()).as_ref(),
548        resource_name = resource.name_unchecked().as_str(),
549        controller = &*instrumentation.name,
550        event_type = Empty,
551        outcome = Empty,
552        success = Empty,
553        duration_seconds = Empty,
554        metadata = Empty,
555    );
556    async {
557        trace!("beginning reconciliation");
558
559        let pass = Arc::new(Pass {
560            controller: Arc::clone(&instrumentation.name),
561            reference: resource.object_ref(&Default::default()),
562            observer: instrumentation.observer.clone(),
563            events: instrumentation.events.clone(),
564        });
565        let mut metadata = TraceMetadata::for_pass(Arc::clone(&pass));
566        let mut guard = PassGuard {
567            pass: Arc::clone(&pass),
568            start: Instant::now(),
569            phase: None,
570            fallback_phase: if resource.meta().deletion_timestamp.is_some() {
571                Phase::Delete
572            } else {
573                Phase::Init
574            },
575            finished: false,
576        };
577        let mut reconciler_outcome = None;
578
579        let res = if let Some(finalizer_name) = Ctx::FINALIZER_NAME {
580            finalizer(&api, finalizer_name, Arc::clone(&resource), |event| async {
581                match event {
582                    FinalizerEvent::Apply(resource) => {
583                        guard.phase = Some(Phase::Apply);
584                        let res = ctx.apply(client, &resource, &mut metadata).await;
585                        reconciler_outcome = Some(Outcome::of_result(&res));
586                        res.map(|action| action.unwrap_or_else(|| ctx.success_action(&resource)))
587                    }
588                    FinalizerEvent::Cleanup(resource) => {
589                        guard.phase = Some(Phase::Cleanup);
590                        let res = ctx.cleanup(client, &resource, &mut metadata).await;
591                        reconciler_outcome = Some(Outcome::of_result(&res));
592                        res.map(|action| action.unwrap_or_else(Action::await_change))
593                    }
594                }
595            })
596            .await
597            .map_err(Error::FinalizerError)
598        } else if resource.meta().deletion_timestamp.is_none() {
599            guard.phase = Some(Phase::Apply);
600            let res = ctx.apply(client, &resource, &mut metadata).await;
601            reconciler_outcome = Some(Outcome::of_result(&res));
602            res.map(|action| action.unwrap_or_else(|| ctx.success_action(&resource)))
603                .map_err(Error::ControllerError)
604        } else {
605            Ok(Action::await_change())
606        };
607
608        let outcome = match &res {
609            Err(_) => Outcome::Failed,
610            Ok(_) => metadata
611                .outcome
612                .or(reconciler_outcome)
613                .unwrap_or(Outcome::Completed),
614        };
615        let phase = guard.phase();
616        let duration = guard.start.elapsed();
617        guard.finish(outcome);
618
619        let span = Span::current();
620        span.record("event_type", phase.as_str());
621        span.record("outcome", outcome.as_str());
622        span.record("duration_seconds", duration.as_secs_f64());
623
624        if !metadata.annotations.is_empty()
625            && let Ok(s) = serde_json::to_string(&metadata.annotations)
626        {
627            span.record("metadata", s);
628        }
629
630        if let Err(e) = &res {
631            span.record("success", false);
632            if e.is_conflict() {
633                // Conflicts are expected whenever the cache lags the API
634                // server, for instance after a leadership change, and the
635                // retry resolves them. Logging them as errors floods error
636                // trackers that report every error event.
637                info!(error = %e, source = e.source(), "reconcile");
638            } else {
639                error!(error = %e, source = e.source(), "reconcile");
640            }
641        } else {
642            span.record("success", true);
643            info!("reconcile");
644        }
645
646        if let Some(events) = &instrumentation.events {
647            match &res {
648                Err(e) => {
649                    if let Some(event) = ctx.failure_event(&resource, phase, e)
650                        && let Err(publish_err) =
651                            events.publish_to(&pass.reference, &event, true).await
652                    {
653                        warn!(
654                            error = %publish_err,
655                            reason = %event.reason,
656                            "failed to publish reconciliation failure event",
657                        );
658                    }
659                }
660                Ok(_) => {
661                    if let Some(uid) = &pass.reference.uid {
662                        events.forget_failures(uid);
663                    }
664                }
665            }
666        }
667
668        res
669    }
670    .instrument(span)
671    .await
672}
673
674#[cfg(test)]
675mod tests {
676    use std::future::pending;
677    use std::sync::Mutex;
678
679    use futures::FutureExt;
680    use k8s_openapi::api::core::v1::ConfigMap;
681    use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time};
682    use k8s_openapi::jiff::Timestamp;
683    use kube::core::Status;
684    use kube::core::response::{StatusCause, StatusDetails, StatusSummary};
685
686    use super::*;
687    use crate::events::Reporter;
688    use crate::observe::{ReconcileRecord, StepRecord};
689    use crate::test_util::{MockApiServer, block_on};
690
691    #[derive(Debug, thiserror::Error)]
692    enum TestError {
693        #[error("reconciling failed: {0}")]
694        Wrapped(#[source] std::io::Error),
695        #[error("talking to the API server failed")]
696        Kube(#[source] kube::Error),
697    }
698
699    #[derive(Clone, Copy)]
700    enum Behavior {
701        Done,
702        Requeue,
703        Skip,
704        Fail,
705        FailQuietly,
706        Hang,
707    }
708
709    struct TestContext {
710        behavior: Mutex<Behavior>,
711    }
712
713    impl TestContext {
714        fn new(behavior: Behavior) -> Arc<Self> {
715            Arc::new(Self {
716                behavior: Mutex::new(behavior),
717            })
718        }
719
720        fn set(&self, behavior: Behavior) {
721            *self.behavior.lock().unwrap() = behavior;
722        }
723    }
724
725    #[async_trait::async_trait]
726    impl Context for TestContext {
727        type Resource = ConfigMap;
728        type Error = TestError;
729
730        async fn apply(
731            &self,
732            _client: Client,
733            _resource: &ConfigMap,
734            metadata: &mut TraceMetadata,
735        ) -> Result<Option<Action>, TestError> {
736            let behavior = *self.behavior.lock().unwrap();
737            let step = metadata.step("work");
738            match behavior {
739                Behavior::Done => {
740                    step.finish(Outcome::Completed);
741                    Ok(None)
742                }
743                Behavior::Requeue => {
744                    step.finish(Outcome::Waiting);
745                    Ok(Some(Action::requeue(Duration::from_secs(1))))
746                }
747                Behavior::Skip => {
748                    step.finish(Outcome::Skipped);
749                    metadata.set_outcome(Outcome::Skipped);
750                    Ok(None)
751                }
752                Behavior::Fail | Behavior::FailQuietly => {
753                    Err(TestError::Wrapped(std::io::Error::other("disk on fire")))
754                }
755                Behavior::Hang => pending().await,
756            }
757        }
758
759        fn failure_event(
760            &self,
761            _resource: &ConfigMap,
762            _phase: Phase,
763            err: &Error<TestError>,
764        ) -> Option<Event> {
765            match *self.behavior.lock().unwrap() {
766                Behavior::FailQuietly => None,
767                _ => Some(Event {
768                    type_: EventType::Warning,
769                    reason: "ReconcileFailed".to_owned(),
770                    action: "Reconcile".to_owned(),
771                    note: Some(err.display_chain()),
772                    related: None,
773                }),
774            }
775        }
776    }
777
778    #[derive(Debug, Clone, PartialEq)]
779    enum Recorded {
780        Pass(String, Phase, Outcome),
781        Step(String, &'static str, Outcome),
782    }
783
784    #[derive(Default)]
785    struct TestObserver(Mutex<Vec<Recorded>>);
786
787    impl TestObserver {
788        fn take(&self) -> Vec<Recorded> {
789            std::mem::take(&mut self.0.lock().unwrap())
790        }
791    }
792
793    impl ReconcileObserver for TestObserver {
794        fn reconciled(&self, record: &ReconcileRecord<'_>) {
795            assert_eq!(record.kind, "ConfigMap");
796            assert_eq!(record.namespace, Some("ns"));
797            assert_eq!(record.name, "cm");
798            self.0.lock().unwrap().push(Recorded::Pass(
799                record.controller.to_owned(),
800                record.phase,
801                record.outcome,
802            ));
803        }
804
805        fn step_finished(&self, record: &StepRecord<'_>) {
806            self.0.lock().unwrap().push(Recorded::Step(
807                record.controller.to_owned(),
808                record.step,
809                record.outcome,
810            ));
811        }
812    }
813
814    struct Harness {
815        server: MockApiServer,
816        observer: Arc<TestObserver>,
817        instrumentation: Arc<Instrumentation>,
818    }
819
820    impl Harness {
821        fn new() -> Self {
822            let server = MockApiServer::new();
823            let observer = Arc::new(TestObserver::default());
824            let instrumentation = Arc::new(Instrumentation {
825                name: "test".into(),
826                observer: Some(Arc::<TestObserver>::clone(&observer)),
827                events: Some(Arc::new(EventRecorder::new(
828                    server.client(),
829                    Reporter {
830                        controller: "test.example.com".to_owned(),
831                        instance: None,
832                    },
833                ))),
834            });
835            Self {
836                server,
837                observer,
838                instrumentation,
839            }
840        }
841
842        fn reconcile(
843            &self,
844            ctx: &Arc<TestContext>,
845            resource: ConfigMap,
846        ) -> impl Future<Output = Result<Action, Error<TestError>>> + use<> {
847            let client = self.server.client();
848            reconcile(
849                Arc::clone(ctx),
850                client.clone(),
851                Api::namespaced(client, "ns"),
852                Arc::new(resource),
853                Arc::clone(&self.instrumentation),
854            )
855        }
856
857        fn methods(&self) -> Vec<String> {
858            self.server
859                .requests()
860                .into_iter()
861                .map(|r| r.method)
862                .collect()
863        }
864    }
865
866    fn config_map() -> ConfigMap {
867        ConfigMap {
868            metadata: ObjectMeta {
869                name: Some("cm".to_owned()),
870                namespace: Some("ns".to_owned()),
871                uid: Some("uid-1".to_owned()),
872                ..Default::default()
873            },
874            ..Default::default()
875        }
876    }
877
878    fn pass(phase: Phase, outcome: Outcome) -> Recorded {
879        Recorded::Pass("test".to_owned(), phase, outcome)
880    }
881
882    fn step(outcome: Outcome) -> Recorded {
883        Recorded::Step("test".to_owned(), "work", outcome)
884    }
885
886    #[test]
887    fn classifies_successful_passes() {
888        block_on(async {
889            let harness = Harness::new();
890            let ctx = TestContext::new(Behavior::Done);
891            harness.reconcile(&ctx, config_map()).await.unwrap();
892            assert_eq!(
893                harness.observer.take(),
894                [
895                    step(Outcome::Completed),
896                    pass(Phase::Apply, Outcome::Completed)
897                ]
898            );
899
900            ctx.set(Behavior::Requeue);
901            harness.reconcile(&ctx, config_map()).await.unwrap();
902            assert_eq!(
903                harness.observer.take(),
904                [step(Outcome::Waiting), pass(Phase::Apply, Outcome::Waiting)]
905            );
906
907            ctx.set(Behavior::Skip);
908            harness.reconcile(&ctx, config_map()).await.unwrap();
909            assert_eq!(
910                harness.observer.take(),
911                [step(Outcome::Skipped), pass(Phase::Apply, Outcome::Skipped)]
912            );
913
914            assert!(harness.server.requests().is_empty());
915        });
916    }
917
918    #[test]
919    fn publishes_and_aggregates_failure_events() {
920        block_on(async {
921            let harness = Harness::new();
922            let ctx = TestContext::new(Behavior::Fail);
923            harness.reconcile(&ctx, config_map()).await.unwrap_err();
924            assert_eq!(
925                harness.observer.take(),
926                [
927                    step(Outcome::Abandoned),
928                    pass(Phase::Apply, Outcome::Failed)
929                ]
930            );
931            let requests = harness.server.requests();
932            assert_eq!(requests[0].body["reason"], "ReconcileFailed");
933            assert_eq!(requests[0].body["note"], "reconciling failed: disk on fire");
934
935            harness.reconcile(&ctx, config_map()).await.unwrap_err();
936            assert_eq!(harness.methods(), ["POST", "PATCH"]);
937
938            // a success ends the series, so the next failure is a new event
939            ctx.set(Behavior::Done);
940            harness.reconcile(&ctx, config_map()).await.unwrap();
941            ctx.set(Behavior::Fail);
942            harness.reconcile(&ctx, config_map()).await.unwrap_err();
943            assert_eq!(harness.methods(), ["POST", "PATCH", "POST"]);
944        });
945    }
946
947    #[test]
948    fn failure_event_can_be_suppressed() {
949        block_on(async {
950            let harness = Harness::new();
951            let ctx = TestContext::new(Behavior::FailQuietly);
952            harness.reconcile(&ctx, config_map()).await.unwrap_err();
953            assert_eq!(
954                harness.observer.take(),
955                [
956                    step(Outcome::Abandoned),
957                    pass(Phase::Apply, Outcome::Failed)
958                ]
959            );
960            assert!(harness.server.requests().is_empty());
961        });
962    }
963
964    #[test]
965    fn deleted_resource_without_finalizer() {
966        block_on(async {
967            let harness = Harness::new();
968            let ctx = TestContext::new(Behavior::Fail);
969            let mut resource = config_map();
970            resource.metadata.deletion_timestamp = Some(Time(Timestamp::now()));
971            harness.reconcile(&ctx, resource).await.unwrap();
972            assert_eq!(
973                harness.observer.take(),
974                [pass(Phase::Delete, Outcome::Completed)]
975            );
976        });
977    }
978
979    #[test]
980    fn cancelled_pass_is_abandoned() {
981        block_on(async {
982            let harness = Harness::new();
983            let ctx = TestContext::new(Behavior::Hang);
984            let mut fut = Box::pin(harness.reconcile(&ctx, config_map()));
985            assert!((&mut fut).now_or_never().is_none());
986            assert!(harness.observer.take().is_empty());
987            drop(fut);
988            assert_eq!(
989                harness.observer.take(),
990                [
991                    step(Outcome::Abandoned),
992                    pass(Phase::Apply, Outcome::Abandoned)
993                ]
994            );
995        });
996    }
997
998    #[test]
999    fn display_chain_does_not_repeat_messages() {
1000        let err: Error<TestError> =
1001            Error::FinalizerError(kube_runtime::finalizer::Error::ApplyFailed(
1002                TestError::Wrapped(std::io::Error::other("disk on fire")),
1003            ));
1004        assert_eq!(
1005            err.display_chain(),
1006            "failed to apply object: reconciling failed: disk on fire"
1007        );
1008
1009        #[derive(Debug, thiserror::Error)]
1010        #[error("outer")]
1011        struct Outer(#[source] std::io::Error);
1012        let err: Error<Outer> = Error::ControllerError(Outer(std::io::Error::other("inner")));
1013        assert_eq!(err.display_chain(), "outer: inner");
1014
1015        #[derive(Debug, thiserror::Error)]
1016        #[error("reading lock: timed out waiting for lock")]
1017        struct Mentions(#[source] std::io::Error);
1018        let err: Error<Mentions> =
1019            Error::ControllerError(Mentions(std::io::Error::other("timed out")));
1020        assert_eq!(
1021            err.display_chain(),
1022            "reading lock: timed out waiting for lock: timed out"
1023        );
1024    }
1025
1026    fn api_error(code: u16, reason: &str, details: Option<StatusDetails>) -> kube::Error {
1027        kube::Error::Api(Box::new(Status {
1028            status: Some(StatusSummary::Failure),
1029            code,
1030            message: String::new(),
1031            metadata: None,
1032            reason: reason.into(),
1033            details,
1034        }))
1035    }
1036
1037    #[test]
1038    fn conflicts_are_classified() {
1039        use kube_runtime::finalizer::Error as F;
1040
1041        let stale_apply: Error<TestError> = Error::FinalizerError(F::ApplyFailed(TestError::Kube(
1042            api_error(409, "Conflict", None),
1043        )));
1044        assert!(
1045            stale_apply.is_conflict(),
1046            "409 nested in the context's error"
1047        );
1048
1049        let stale_controller: Error<TestError> =
1050            Error::ControllerError(TestError::Kube(api_error(409, "Conflict", None)));
1051        assert!(stale_controller.is_conflict(), "409 without a finalizer");
1052
1053        let failed_test = || {
1054            api_error(
1055                422,
1056                "Invalid",
1057                Some(StatusDetails {
1058                    name: String::new(),
1059                    group: String::new(),
1060                    kind: String::new(),
1061                    uid: String::new(),
1062                    causes: vec![],
1063                    retry_after_seconds: 0,
1064                }),
1065            )
1066        };
1067        let add: Error<TestError> = Error::FinalizerError(F::AddFinalizer(failed_test()));
1068        assert!(
1069            add.is_conflict(),
1070            "failed patch test when adding the finalizer"
1071        );
1072        let remove: Error<TestError> = Error::FinalizerError(F::RemoveFinalizer(failed_test()));
1073        assert!(
1074            remove.is_conflict(),
1075            "failed patch test when removing the finalizer"
1076        );
1077    }
1078
1079    #[test]
1080    fn other_errors_are_not_conflicts() {
1081        use kube_runtime::finalizer::Error as F;
1082
1083        let exists: Error<TestError> =
1084            Error::ControllerError(TestError::Kube(api_error(409, "AlreadyExists", None)));
1085        assert!(
1086            !exists.is_conflict(),
1087            "AlreadyExists does not resolve on retry"
1088        );
1089
1090        let invalid = api_error(
1091            422,
1092            "Invalid",
1093            Some(StatusDetails {
1094                name: "cm".into(),
1095                group: String::new(),
1096                kind: "ConfigMap".into(),
1097                uid: String::new(),
1098                causes: vec![StatusCause {
1099                    reason: "FieldValueInvalid".into(),
1100                    message: "bad value".into(),
1101                    field: "metadata.finalizers[0]".into(),
1102                }],
1103                retry_after_seconds: 0,
1104            }),
1105        );
1106        let add: Error<TestError> = Error::FinalizerError(F::AddFinalizer(invalid));
1107        assert!(
1108            !add.is_conflict(),
1109            "validation failure of the finalizer patch"
1110        );
1111
1112        let invalid_apply: Error<TestError> = Error::FinalizerError(F::ApplyFailed(
1113            TestError::Kube(api_error(422, "Invalid", None)),
1114        ));
1115        assert!(
1116            !invalid_apply.is_conflict(),
1117            "422 from the context's own writes"
1118        );
1119
1120        let io: Error<TestError> =
1121            Error::ControllerError(TestError::Wrapped(std::io::Error::other("disk on fire")));
1122        assert!(!io.is_conflict());
1123    }
1124}