staircase 0.0.7

Kubernetes Step-based Operator
Documentation
//! Helpers for describing Kubernetes resources owned by a custom resource.
//!
//! These traits let you describe dependent resources in a reusable way, so
//! other crates can derive names, labels, APIs, or generated resources from the
//! owning custom resource.

use std::fmt::Debug;

use serde::de::DeserializeOwned;

/// Describes one dependent resource managed for a custom resource.
///
/// For example, assume you have a `Job` custom resource and want to manage
/// `Pod` resources for it:
/// ```rust
/// # use staircase::resources::ComponentizedResource;
///
/// #[derive(PartialEq, Eq, Debug)]
/// pub enum Components {
///     Pod,
/// }
///
/// pub struct JobPod;
/// impl ComponentizedResource for JobPod {
///     type Component = Components;
///     type Resource = k8s_openapi::api::core::v1::Pod;
///
///     const COMPONENT: Self::Component = Components::Pod;
/// }
/// ```
///
/// This is especially useful when there are multiple dependent resources. Use
/// the component value for additional data or to group dependent resources.
pub trait ComponentizedResource {
    type Component: PartialEq + Eq + Debug;
    const COMPONENT: Self::Component;
    type Resource: kube::Resource<DynamicType: Default> + DeserializeOwned + Clone + Debug;
}

/// Implement this trait when a componentized resource exists at most once per
/// owning custom resource and its name can be derived statically.
pub trait NamedResource<T: ComponentizedResource> {
    fn name(&self) -> String;

    /// Namespace for namespaced resources.
    ///
    /// If the resource uses [`kube::core::NamespaceResourceScope`], this must
    /// always return `Some`.
    fn ns(&self) -> Option<String>;
}

/// Implement this trait when [`NamedResource`] is not enough, e.g. a
/// componentized resource exists more than once per owning CR.
///
/// The owner must be able to derive a label selector that fetches all matching
/// dependent resources, similar to how a `Deployment` selects its `Pod`s.
pub trait LabeledResource<T: ComponentizedResource> {
    fn selector_all(&self) -> String;
}

/// Provides an API handle for a componentized resource.
///
/// This chooses a namespaced or cluster-scoped [`kube::Api`] based on the
/// dependent resource scope. This is useful because `Api::all` is limited to
/// `list` and `watch`; see the warning in the `kube::Api` documentation.
pub trait ApiResource<T>
where
    T: ComponentizedResource,
    <T as ComponentizedResource>::Resource: Sized,
{
    fn api(&self, client: kube::Client) -> kube::Api<<T as ComponentizedResource>::Resource>;
}

impl<T, R> ApiResource<T> for R
where
    T: ComponentizedResource,
    <T as ComponentizedResource>::Resource: kube::Resource + Sized,
    <<T as ComponentizedResource>::Resource as kube::Resource>::Scope: api_resource_scope::Dispatch<T>,
    R: kube::Resource,
{
    fn api(&self, client: kube::Client) -> kube::Api<<T as ComponentizedResource>::Resource> {
        <<<T as ComponentizedResource>::Resource as kube::Resource>::Scope as api_resource_scope::Dispatch<T>>::api(
            self, client,
        )
    }
}

mod api_resource_scope {
    use super::ComponentizedResource;

    // Internal dispatch for the two normal Kubernetes resource scopes.
    pub(super) trait Dispatch<T: ComponentizedResource> {
        fn api<R: kube::Resource>(owner: &R, client: kube::Client)
        -> kube::Api<<T as ComponentizedResource>::Resource>;
    }

    impl<T> Dispatch<T> for kube::core::NamespaceResourceScope
    where
        T: ComponentizedResource,
        <T as ComponentizedResource>::Resource: kube::Resource<Scope = kube::core::NamespaceResourceScope>,
    {
        fn api<R: kube::Resource>(
            owner: &R,
            client: kube::Client,
        ) -> kube::Api<<T as ComponentizedResource>::Resource> {
            kube::Api::namespaced(client, owner.meta().namespace.as_ref().unwrap())
        }
    }

    impl<T> Dispatch<T> for kube::core::ClusterResourceScope
    where
        T: ComponentizedResource,
        <T as ComponentizedResource>::Resource: kube::Resource<Scope = kube::core::ClusterResourceScope>,
    {
        fn api<R: kube::Resource>(_: &R, client: kube::Client) -> kube::Api<<T as ComponentizedResource>::Resource> {
            kube::Api::all(client)
        }
    }
}

/// Generates a Kubernetes resource from an owning custom resource and config.
pub trait ResourceGenerator<T: ComponentizedResource> {
    /// Custom config required to generate the resource.
    type GenConfig;
    /// Error returned when the resource cannot be generated, for example
    /// because the custom resource spec does not require it or config is
    /// incomplete.
    type Error;

    fn generate(&self, config: Self::GenConfig) -> std::result::Result<T::Resource, Self::Error>;
}

#[cfg(test)]
mod tests {
    use std::convert::Infallible;

    use k8s_openapi::{
        api::core::v1::{Namespace, Pod},
        apimachinery::pkg::apis::meta::v1::ObjectMeta,
    };
    use kube::{CustomResource, client::Body};
    use schemars::JsonSchema;
    use serde::{Deserialize, Serialize};

    use super::*;

    #[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
    #[kube(group = "tests.staircase.dev", version = "v1", kind = "Owner", namespaced)]
    struct OwnerSpec {}

    struct PodComponent;

    impl ComponentizedResource for PodComponent {
        type Component = ();
        type Resource = Pod;

        const COMPONENT: Self::Component = ();
    }

    struct NamespaceComponent;

    impl ComponentizedResource for NamespaceComponent {
        type Component = ();
        type Resource = Namespace;

        const COMPONENT: Self::Component = ();
    }

    fn dummy_client() -> kube::Client {
        let service = tower::service_fn(|_request: http::Request<Body>| async {
            Ok::<_, Infallible>(http::Response::new(Body::empty()))
        });
        kube::Client::new(service, "default")
    }

    fn owner() -> Owner {
        Owner {
            metadata: ObjectMeta {
                name: Some("sample".to_string()),
                namespace: Some("owner-ns".to_string()),
                ..Default::default()
            },
            spec:     OwnerSpec {},
        }
    }

    #[test]
    fn api_resource_uses_owner_namespace_for_namespaced_resources() {
        let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
        let _guard = rt.enter();
        let api = ApiResource::<PodComponent>::api(&owner(), dummy_client());

        assert_eq!(api.namespace(), Some("owner-ns"));
        assert_eq!(api.resource_url(), "/api/v1/namespaces/owner-ns/pods");
    }

    #[test]
    fn api_resource_uses_all_namespaces_for_cluster_resources() {
        let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
        let _guard = rt.enter();
        let api = ApiResource::<NamespaceComponent>::api(&owner(), dummy_client());

        assert_eq!(api.namespace(), None);
        assert_eq!(api.resource_url(), "/api/v1/namespaces");
    }
}