diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index 08fd8ad53..cc288ab83 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -48,6 +48,15 @@ All notable changes to this project will be documented in this file. - BREAKING: Inject vector aggregator address into vector config file using an environment variable ([#1000]). +### Changed + +- BREAKING: Version common CRD structs and enums ([#968]). + - All CRD-related types and function now reside in the `stackable_operator::crd` module. + - Each CRD-related struct and enum has been versioned. The initial version is `v1alpha1`. + - The `static` authentication provider must now be imported using `r#static`. + - Import are now more granular in general. + +[#968]: https://github.com/stackabletech/operator-rs/pull/968 [#1000]: https://github.com/stackabletech/operator-rs/pull/1000 ## [0.89.1] - 2025-04-02 diff --git a/crates/stackable-operator/src/cluster_resources.rs b/crates/stackable-operator/src/cluster_resources.rs index 44daa3c4e..73d63269a 100644 --- a/crates/stackable-operator/src/cluster_resources.rs +++ b/crates/stackable-operator/src/cluster_resources.rs @@ -32,12 +32,12 @@ use crate::{ client::{Client, GetApi}, commons::{ cluster_operation::ClusterOperation, - listener::Listener, resources::{ ComputeResource, LIMIT_REQUEST_RATIO_CPU, LIMIT_REQUEST_RATIO_MEMORY, ResourceRequirementsExt, ResourceRequirementsType, }, }, + crd::listener, kvp::{ Label, LabelError, Labels, consts::{K8S_APP_INSTANCE_KEY, K8S_APP_MANAGED_BY_KEY, K8S_APP_NAME_KEY}, @@ -205,7 +205,7 @@ impl ClusterResource for Service {} impl ClusterResource for ServiceAccount {} impl ClusterResource for RoleBinding {} impl ClusterResource for PodDisruptionBudget {} -impl ClusterResource for Listener {} +impl ClusterResource for listener::v1alpha1::Listener {} impl ClusterResource for Job { fn pod_spec(&self) -> Option<&PodSpec> { @@ -646,7 +646,7 @@ impl ClusterResources { self.delete_orphaned_resources_of_kind::(client), self.delete_orphaned_resources_of_kind::(client), self.delete_orphaned_resources_of_kind::(client), - self.delete_orphaned_resources_of_kind::(client), + self.delete_orphaned_resources_of_kind::(client), )?; Ok(()) diff --git a/crates/stackable-operator/src/commons/authentication/mod.rs b/crates/stackable-operator/src/commons/authentication/mod.rs deleted file mode 100644 index e462b28e1..000000000 --- a/crates/stackable-operator/src/commons/authentication/mod.rs +++ /dev/null @@ -1,202 +0,0 @@ -use kube::CustomResource; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use snafu::{OptionExt, Snafu}; -use strum::Display; - -use crate::client::Client; - -pub mod kerberos; -pub mod ldap; -pub mod oidc; -pub mod static_; -pub mod tls; - -pub(crate) const SECRET_BASE_PATH: &str = "/stackable/secrets"; - -type Result = std::result::Result; - -#[derive(Debug, PartialEq, Snafu)] -pub enum Error { - #[snafu(display( - "authentication details for OIDC were not specified. The AuthenticationClass {auth_class_name:?} uses an OIDC provider, you need to specify OIDC authentication details (such as client credentials) as well" - ))] - OidcAuthenticationDetailsNotSpecified { auth_class_name: String }, -} - -/// The Stackable Platform uses the AuthenticationClass as a central mechanism to handle user authentication across supported products. -/// The authentication mechanism needs to be configured only in the AuthenticationClass which is then referenced in the product. -/// Multiple different authentication providers are supported. -/// Learn more in the [authentication concept documentation](DOCS_BASE_URL_PLACEHOLDER/concepts/authentication) and the -/// [Authentication with OpenLDAP tutorial](DOCS_BASE_URL_PLACEHOLDER/tutorials/authentication_with_openldap). -#[derive( - Clone, - CustomResource, - Debug, - Deserialize, - Eq, - Hash, - JsonSchema, - Ord, - PartialEq, - PartialOrd, - Serialize, -)] -#[kube( - group = "authentication.stackable.tech", - version = "v1alpha1", - kind = "AuthenticationClass", - plural = "authenticationclasses", - crates( - kube_core = "kube::core", - k8s_openapi = "k8s_openapi", - schemars = "schemars" - ) -)] -#[serde(rename_all = "camelCase")] -pub struct AuthenticationClassSpec { - /// Provider used for authentication like LDAP or Kerberos. - pub provider: AuthenticationClassProvider, -} - -#[derive( - Clone, Debug, Deserialize, Display, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, -)] -#[serde(rename_all = "camelCase")] -#[allow(clippy::large_enum_variant)] -pub enum AuthenticationClassProvider { - /// The [static provider](https://DOCS_BASE_URL_PLACEHOLDER/concepts/authentication#_static) is used to configure a - /// static set of users, identified by username and password. - Static(static_::AuthenticationProvider), - - /// The [LDAP provider](DOCS_BASE_URL_PLACEHOLDER/concepts/authentication#_ldap). - /// There is also the ["Authentication with LDAP" tutorial](DOCS_BASE_URL_PLACEHOLDER/tutorials/authentication_with_openldap) - /// where you can learn to configure Superset and Trino with OpenLDAP. - Ldap(ldap::AuthenticationProvider), - - /// The OIDC provider can be used to configure OpenID Connect. - Oidc(oidc::AuthenticationProvider), - - /// The [TLS provider](DOCS_BASE_URL_PLACEHOLDER/concepts/authentication#_tls). - /// The TLS AuthenticationClass is used when users should authenticate themselves with a TLS certificate. - Tls(tls::AuthenticationProvider), - - /// The [Kerberos provider](DOCS_BASE_URL_PLACEHOLDER/concepts/authentication#_kerberos). - /// The Kerberos AuthenticationClass is used when users should authenticate themselves via Kerberos. - Kerberos(kerberos::AuthenticationProvider), -} - -impl AuthenticationClass { - pub async fn resolve( - client: &Client, - authentication_class_name: &str, - ) -> crate::client::Result { - client - .get::(authentication_class_name, &()) // AuthenticationClass has ClusterScope - .await - } -} - -/// Common [`ClientAuthenticationDetails`] which is specified at the client/ -/// product cluster level. It provides a name (key) to resolve a particular -/// [`AuthenticationClass`]. Additionally, it provides authentication provider -/// specific configuration (OIDC and LDAP for example). -/// -/// If the product needs additional (product specific) authentication options, -/// it is recommended to wrap this struct and use `#[serde(flatten)]` on the -/// field. -/// -/// Additionally, it might be the case that special fields are needed in the -/// contained structs, such as [`oidc::ClientAuthenticationOptions`]. To be able -/// to add custom fields in that structs without serde(flattening) multiple structs, -/// they are generic, so you can add additional attributes if needed. -/// -/// ### Example -/// -/// ``` -/// # use schemars::JsonSchema; -/// # use serde::{Deserialize, Serialize}; -/// use stackable_operator::commons::authentication::ClientAuthenticationDetails; -/// -/// #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] -/// #[serde(rename_all = "camelCase")] -/// pub struct SupersetAuthenticationClass { -/// pub user_registration_role: String, -/// pub user_registration: bool, -/// -/// #[serde(flatten)] -/// pub common: ClientAuthenticationDetails, -/// } -/// ``` -#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -#[schemars(description = "")] -pub struct ClientAuthenticationDetails { - /// Name of the [AuthenticationClass](https://docs.stackable.tech/home/nightly/concepts/authentication) used to - /// authenticate users. - // - // To get the concrete [`AuthenticationClass`], we must resolve it. This resolution can be achieved by using - // [`ClientAuthenticationDetails::resolve_class`]. - #[serde(rename = "authenticationClass")] - authentication_class_ref: String, - - /// This field contains OIDC-specific configuration. It is only required in case OIDC is used. - // - // Use [`ClientAuthenticationDetails::oidc_or_error`] to get the value or report an error to the user. - // TODO: Ideally we want this to be an enum once other `ClientAuthenticationOptions` are added, so - // that user can not configure multiple options at the same time (yes we are aware that this makes a - // changing the type of an AuthenticationClass harder). - // This is a non-breaking change though :) - oidc: Option>, -} - -impl ClientAuthenticationDetails { - /// Resolves this specific [`AuthenticationClass`]. Usually products support - /// a list of authentication classes, which individually need to be resolved.crate::client - pub async fn resolve_class( - &self, - client: &Client, - ) -> crate::client::Result { - AuthenticationClass::resolve(client, &self.authentication_class_ref).await - } - - pub fn authentication_class_name(&self) -> &String { - &self.authentication_class_ref - } - - /// In case OIDC is configured, the user *needs* to provide some connection details, - /// such as the client credentials. Call this function in case the user has configured - /// OIDC, as it will error out then the OIDC client details are missing. - pub fn oidc_or_error( - &self, - auth_class_name: &str, - ) -> Result<&oidc::ClientAuthenticationOptions> { - self.oidc - .as_ref() - .with_context(|| OidcAuthenticationDetailsNotSpecifiedSnafu { - auth_class_name: auth_class_name.to_string(), - }) - } -} - -#[cfg(test)] -mod tests { - use crate::commons::authentication::{ - AuthenticationClassProvider, tls::AuthenticationProvider, - }; - - #[test] - fn provider_to_string() { - let tls_provider = AuthenticationClassProvider::Tls(AuthenticationProvider { - client_cert_secret_class: None, - }); - assert_eq!("Tls", tls_provider.to_string()); - - let kerberos_provider = AuthenticationClassProvider::Kerberos( - crate::commons::authentication::kerberos::AuthenticationProvider { - kerberos_secret_class: "kerberos".to_string(), - }, - ); - assert_eq!("Kerberos", kerberos_provider.to_string()); - } -} diff --git a/crates/stackable-operator/src/commons/listener.rs b/crates/stackable-operator/src/commons/listener.rs deleted file mode 100644 index b088e3950..000000000 --- a/crates/stackable-operator/src/commons/listener.rs +++ /dev/null @@ -1,288 +0,0 @@ -//! This modules provides resource types used to interact with [listener-operator](https://docs.stackable.tech/listener-operator/stable/index.html) -//! -//! # Custom Resources -//! -//! ## [`Listener`] -//! -//! Exposes a set of pods, either internally to the cluster or to the outside world. The mechanism for how it is exposed -//! is managed by the [`ListenerClass`]. -//! -//! It can be either created manually by the application administrator (for applications that expose a single load-balanced endpoint), -//! or automatically when mounting a [listener volume](`ListenerOperatorVolumeSourceBuilder`) (for applications that expose a separate endpoint -//! per replica). -//! -//! All exposed pods *must* have a mounted [listener volume](`ListenerOperatorVolumeSourceBuilder`), regardless of whether the [`Listener`] is created automatically. -//! -//! ## [`ListenerClass`] -//! -//! Declares a policy for how [`Listener`]s are exposed to users. -//! -//! It is created by the cluster administrator. -//! -//! ## [`PodListeners`] -//! -//! Informs users and other operators about the state of all [`Listener`]s associated with a [`Pod`]. -//! -//! It is created by the Stackable Secret Operator, and always named `pod-{pod.metadata.uid}`. - -use std::collections::BTreeMap; - -#[cfg(doc)] -use k8s_openapi::api::core::v1::{ - Node, PersistentVolume, PersistentVolumeClaim, Pod, Service, Volume, -}; -use kube::CustomResource; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[cfg(doc)] -use crate::builder::pod::volume::ListenerOperatorVolumeSourceBuilder; - -/// Defines a policy for how [Listeners](DOCS_BASE_URL_PLACEHOLDER/listener-operator/listener) should be exposed. -/// Read the [ListenerClass documentation](DOCS_BASE_URL_PLACEHOLDER/listener-operator/listenerclass) -/// for more information. -#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, JsonSchema, PartialEq, Eq)] -#[kube( - group = "listeners.stackable.tech", - version = "v1alpha1", - kind = "ListenerClass" -)] -#[serde(rename_all = "camelCase")] -pub struct ListenerClassSpec { - pub service_type: ServiceType, - - /// Annotations that should be added to the Service object. - #[serde(default)] - pub service_annotations: BTreeMap, - - /// `externalTrafficPolicy` that should be set on the created [`Service`] objects. - /// - /// The default is `Local` (in contrast to `Cluster`), as we aim to direct traffic to a node running the workload - /// and we should keep testing that as the primary configuration. Cluster is a fallback option for providers that - /// break Local mode (IONOS so far). - #[serde(default = "ListenerClassSpec::default_service_external_traffic_policy")] - pub service_external_traffic_policy: KubernetesTrafficPolicy, - - /// Whether addresses should prefer using the IP address (`IP`) or the hostname (`Hostname`). - /// Can also be set to `HostnameConservative`, which will use `IP` for `NodePort` service types, but `Hostname` for everything else. - /// - /// The other type will be used if the preferred type is not available. - /// - /// Defaults to `HostnameConservative`. - #[serde(default = "ListenerClassSpec::default_preferred_address_type")] - pub preferred_address_type: PreferredAddressType, -} - -impl ListenerClassSpec { - const fn default_service_external_traffic_policy() -> KubernetesTrafficPolicy { - KubernetesTrafficPolicy::Local - } - - const fn default_preferred_address_type() -> PreferredAddressType { - PreferredAddressType::HostnameConservative - } - - /// Resolves [`Self::preferred_address_type`]'s "smart" modes depending on the rest of `self`. - pub fn resolve_preferred_address_type(&self) -> AddressType { - self.preferred_address_type.resolve(self) - } -} - -/// The method used to access the services. -// -// Please note that this does not necessarely need to be restricted to the same Service types Kubernetes supports. -// Listeners currently happens to support the same set of service types as upstream Kubernetes, but we still want to -// have the freedom to add custom ones in the future (for example: Istio ingress?). -#[derive(Serialize, Deserialize, Clone, Copy, Debug, JsonSchema, PartialEq, Eq)] -pub enum ServiceType { - /// Reserve a port on each node. - NodePort, - - /// Provision a dedicated load balancer. - LoadBalancer, - - /// Assigns an IP address from a pool of IP addresses that your cluster has reserved for that purpose. - ClusterIP, -} - -/// Service Internal Traffic Policy enables internal traffic restrictions to only route internal traffic to endpoints -/// within the node the traffic originated from. The "internal" traffic here refers to traffic originated from Pods in -/// the current cluster. This can help to reduce costs and improve performance. -/// See [Kubernetes docs](https://kubernetes.io/docs/concepts/services-networking/service-traffic-policy/). -// -// Please note that this represents a Kubernetes type, so the name of the enum variant needs to exactly match the -// Kubernetes traffic policy. -#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, PartialEq, Eq, strum::Display)] -pub enum KubernetesTrafficPolicy { - /// Obscures the client source IP and may cause a second hop to another node, but allows Kubernetes to spread the load between all nodes. - Cluster, - - /// Preserves the client source IP and avoid a second hop for LoadBalancer and NodePort type Services, but makes clients responsible for spreading the load. - Local, -} - -/// Exposes a set of pods to the outside world. -/// -/// Essentially a Stackable extension of a Kubernetes Service. Compared to a Service, a Listener changes three things: -/// 1. It uses a cluster-level policy object (ListenerClass) to define how exactly the exposure works -/// 2. It has a consistent API for reading back the exposed address(es) of the service -/// 3. The Pod must mount a Volume referring to the Listener, which also allows -/// ["sticky" scheduling](DOCS_BASE_URL_PLACEHOLDER/listener-operator/listener#_sticky_scheduling). -/// -/// Learn more in the [Listener documentation](DOCS_BASE_URL_PLACEHOLDER/listener-operator/listener). -#[derive( - CustomResource, Serialize, Deserialize, Default, Clone, Debug, JsonSchema, PartialEq, Eq, -)] -#[kube( - group = "listeners.stackable.tech", - version = "v1alpha1", - kind = "Listener", - namespaced, - status = "ListenerStatus" -)] -#[serde(rename_all = "camelCase")] -pub struct ListenerSpec { - /// The name of the [ListenerClass](DOCS_BASE_URL_PLACEHOLDER/listener-operator/listenerclass). - pub class_name: Option, - - /// Extra labels that the Pods must match in order to be exposed. They must _also_ still have a Volume referring to the Listener. - #[serde(default)] - pub extra_pod_selector_labels: BTreeMap, - - /// Ports that should be exposed. - pub ports: Option>, - - /// Whether incoming traffic should also be directed to Pods that are not `Ready`. - #[serde(default = "ListenerSpec::default_publish_not_ready_addresses")] - pub publish_not_ready_addresses: Option, -} - -impl ListenerSpec { - const fn default_publish_not_ready_addresses() -> Option { - Some(true) - } -} - -#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ListenerPort { - /// The name of the port. - /// - /// The name of each port *must* be unique within a single Listener. - pub name: String, - /// The port number. - pub port: i32, - /// The layer-4 protocol (`TCP` or `UDP`). - pub protocol: Option, -} - -/// Informs users about how to reach the Listener. -#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ListenerStatus { - /// The backing Kubernetes Service. - pub service_name: Option, - /// All addresses that the Listener is currently reachable from. - pub ingress_addresses: Option>, - /// Port mappings for accessing the Listener on each Node that the Pods are currently running on. - /// - /// This is only intended for internal use by listener-operator itself. This will be left unset if using a ListenerClass that does - /// not require Node-local access. - pub node_ports: Option>, -} - -/// One address that a Listener is accessible from. -#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ListenerIngress { - /// The hostname or IP address to the Listener. - pub address: String, - /// The type of address (`Hostname` or `IP`). - pub address_type: AddressType, - /// Port mapping table. - pub ports: BTreeMap, -} - -/// The type of a given address. -#[derive(Serialize, Deserialize, Clone, Copy, Debug, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "PascalCase")] -pub enum AddressType { - /// A resolvable DNS hostname. - Hostname, - - /// A resolved IP address. - #[serde(rename = "IP")] - Ip, -} - -/// A mode for deciding the preferred [`AddressType`]. -/// -/// These can vary depending on the rest of the [`ListenerClass`]. -#[derive(Serialize, Deserialize, Clone, Copy, Debug, JsonSchema, PartialEq, Eq)] -pub enum PreferredAddressType { - /// Like [`AddressType::Hostname`], but prefers [`AddressType::Ip`] for [`ServiceType::NodePort`], since their hostnames are less likely to be resolvable. - HostnameConservative, - - // Like the respective variants of AddressType. Ideally we would refer to them instead of copy/pasting, but that breaks due to upstream issues: - // - https://github.com/GREsau/schemars/issues/222 - // - https://github.com/kube-rs/kube/issues/1622 - Hostname, - #[serde(rename = "IP")] - Ip, -} - -impl PreferredAddressType { - pub fn resolve(self, listener_class: &ListenerClassSpec) -> AddressType { - match self { - PreferredAddressType::HostnameConservative => match listener_class.service_type { - ServiceType::NodePort => AddressType::Ip, - _ => AddressType::Hostname, - }, - PreferredAddressType::Hostname => AddressType::Hostname, - PreferredAddressType::Ip => AddressType::Ip, - } - } -} - -/// Informs users about Listeners that are bound by a given Pod. -/// -/// This is not expected to be created or modified by users. It will be created by -/// the Stackable Listener Operator when mounting the listener volume, and is always -/// named `pod-{pod.metadata.uid}`. -#[derive( - CustomResource, Serialize, Deserialize, Clone, Debug, JsonSchema, Default, PartialEq, Eq, -)] -#[kube( - group = "listeners.stackable.tech", - version = "v1alpha1", - kind = "PodListeners", - namespaced, - plural = "podlisteners" -)] -#[serde(rename_all = "camelCase")] -pub struct PodListenersSpec { - /// All Listeners currently bound by the Pod. - /// - /// Indexed by Volume name (not PersistentVolume or PersistentVolumeClaim). - pub listeners: BTreeMap, -} - -#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct PodListener { - /// `Node` if this address only allows access to Pods hosted on a specific Kubernetes Node, otherwise `Cluster`. - pub scope: PodListenerScope, - /// Addresses allowing access to this Pod. - /// - /// Compared to `ingress_addresses` on the Listener status, this list is restricted to addresses that can access this Pod. - /// - /// This field is intended to be equivalent to the files mounted into the Listener volume. - pub ingress_addresses: Option>, -} - -#[derive(Serialize, Deserialize, Clone, Copy, Debug, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "PascalCase")] -pub enum PodListenerScope { - Node, - Cluster, -} diff --git a/crates/stackable-operator/src/commons/mod.rs b/crates/stackable-operator/src/commons/mod.rs index b29d078ae..21a778f83 100644 --- a/crates/stackable-operator/src/commons/mod.rs +++ b/crates/stackable-operator/src/commons/mod.rs @@ -1,17 +1,14 @@ //! This module provides common datastructures or CRDs shared between all the operators pub mod affinity; -pub mod authentication; pub mod cache; pub mod cluster_operation; -pub mod listener; pub mod networking; pub mod opa; pub mod pdb; pub mod product_image_selection; pub mod rbac; pub mod resources; -pub mod s3; pub mod secret; pub mod secret_class; pub mod tls_verification; diff --git a/crates/stackable-operator/src/commons/s3/crd.rs b/crates/stackable-operator/src/commons/s3/crd.rs deleted file mode 100644 index 00b796876..000000000 --- a/crates/stackable-operator/src/commons/s3/crd.rs +++ /dev/null @@ -1,129 +0,0 @@ -use kube::CustomResource; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -use crate::commons::{ - networking::HostName, s3::S3ConnectionInlineOrReference, secret_class::SecretClassVolume, - tls_verification::TlsClientDetails, -}; - -/// S3 bucket specification containing the bucket name and an inlined or referenced connection specification. -/// Learn more on the [S3 concept documentation](DOCS_BASE_URL_PLACEHOLDER/concepts/s3). -#[derive(Clone, CustomResource, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] -#[kube( - group = "s3.stackable.tech", - version = "v1alpha1", - kind = "S3Bucket", - plural = "s3buckets", - crates( - kube_core = "kube::core", - k8s_openapi = "k8s_openapi", - schemars = "schemars" - ), - namespaced -)] -#[serde(rename_all = "camelCase")] -pub struct S3BucketSpec { - /// The name of the S3 bucket. - pub bucket_name: String, - - /// The definition of an S3 connection, either inline or as a reference. - pub connection: S3ConnectionInlineOrReference, -} - -/// S3 connection definition as a resource. -/// Learn more on the [S3 concept documentation](DOCS_BASE_URL_PLACEHOLDER/concepts/s3). -#[derive(CustomResource, Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] -#[kube( - group = "s3.stackable.tech", - version = "v1alpha1", - kind = "S3Connection", - plural = "s3connections", - crates( - kube_core = "kube::core", - k8s_openapi = "k8s_openapi", - schemars = "schemars" - ), - namespaced -)] -#[serde(rename_all = "camelCase")] -pub struct S3ConnectionSpec { - /// Host of the S3 server without any protocol or port. For example: `west1.my-cloud.com`. - pub host: HostName, - - /// Port the S3 server listens on. - /// If not specified the product will determine the port to use. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub port: Option, - - /// Bucket region used for signing headers (sigv4). - /// - /// This defaults to `us-east-1` which is compatible with other implementations such as Minio. - /// - /// WARNING: Some products use the Hadoop S3 implementation which falls back to us-east-2. - #[serde(default)] - pub region: Region, - - /// Which access style to use. - /// Defaults to virtual hosted-style as most of the data products out there. - /// Have a look at the [AWS documentation](https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html). - #[serde(default)] - pub access_style: S3AccessStyle, - - /// If the S3 uses authentication you have to specify you S3 credentials. - /// In the most cases a [SecretClass](DOCS_BASE_URL_PLACEHOLDER/secret-operator/secretclass) - /// providing `accessKey` and `secretKey` is sufficient. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub credentials: Option, - - /// Use a TLS connection. If not specified no TLS will be used. - #[serde(flatten)] - pub tls: TlsClientDetails, -} - -#[derive( - strum::Display, Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize, -)] -#[strum(serialize_all = "PascalCase")] -pub enum S3AccessStyle { - /// Use path-style access as described in - Path, - - /// Use as virtual hosted-style access as described in - #[default] - VirtualHosted, -} - -/// Set a named S3 Bucket region. -#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Region { - #[serde(default = "Region::default_region_name")] - pub name: String, -} - -impl Region { - /// Having it as `const &str` as well, so we don't always allocate a [`String`] just for comparisons - pub const DEFAULT_REGION_NAME: &str = "us-east-1"; - - fn default_region_name() -> String { - Self::DEFAULT_REGION_NAME.to_string() - } - - /// Returns if the region sticks to the Stackable defaults. - /// - /// Some products don't really support configuring the region. - /// This function can be used to determine if a warning or error should be raised to inform the - /// user of this situation. - pub fn is_default_config(&self) -> bool { - self.name == Self::DEFAULT_REGION_NAME - } -} - -impl Default for Region { - fn default() -> Self { - Self { - name: Self::default_region_name(), - } - } -} diff --git a/crates/stackable-operator/src/commons/s3/mod.rs b/crates/stackable-operator/src/commons/s3/mod.rs deleted file mode 100644 index 8ed68d810..000000000 --- a/crates/stackable-operator/src/commons/s3/mod.rs +++ /dev/null @@ -1,43 +0,0 @@ -mod crd; -mod helpers; - -pub use crd::*; -pub use helpers::*; -use snafu::Snafu; -use url::Url; - -use crate::commons::{ - secret_class::SecretClassVolumeError, tls_verification::TlsClientDetailsError, -}; - -#[derive(Debug, Snafu)] -pub enum S3Error { - #[snafu(display("failed to retrieve S3 connection '{s3_connection}'"))] - RetrieveS3Connection { - source: crate::client::Error, - s3_connection: String, - }, - - #[snafu(display("failed to parse S3 endpoint '{endpoint}'"))] - ParseS3Endpoint { - source: url::ParseError, - endpoint: String, - }, - - #[snafu(display("failed to set S3 endpoint scheme '{scheme}' for endpoint '{endpoint}'"))] - SetS3EndpointScheme { endpoint: Url, scheme: String }, - - #[snafu(display("failed to add S3 credential volumes and volume mounts"))] - AddS3CredentialVolumes { source: SecretClassVolumeError }, - - #[snafu(display("failed to add S3 TLS client details volumes and volume mounts"))] - AddS3TlsClientDetailsVolumes { source: TlsClientDetailsError }, - - #[snafu(display("failed to add required volumes"))] - AddVolumes { source: crate::builder::pod::Error }, - - #[snafu(display("failed to add required volumeMounts"))] - AddVolumeMounts { - source: crate::builder::pod::container::Error, - }, -} diff --git a/crates/stackable-operator/src/commons/tls_verification.rs b/crates/stackable-operator/src/commons/tls_verification.rs index 47a17c751..1e399b0cf 100644 --- a/crates/stackable-operator/src/commons/tls_verification.rs +++ b/crates/stackable-operator/src/commons/tls_verification.rs @@ -8,10 +8,8 @@ use crate::{ self, pod::{PodBuilder, container::ContainerBuilder, volume::VolumeMountBuilder}, }, - commons::{ - authentication::SECRET_BASE_PATH, - secret_class::{SecretClassVolume, SecretClassVolumeError}, - }, + commons::secret_class::{SecretClassVolume, SecretClassVolumeError}, + constants::secret::SECRET_BASE_PATH, }; #[derive(Debug, Snafu)] diff --git a/crates/stackable-operator/src/constants/mod.rs b/crates/stackable-operator/src/constants/mod.rs new file mode 100644 index 000000000..73b12dbbd --- /dev/null +++ b/crates/stackable-operator/src/constants/mod.rs @@ -0,0 +1 @@ +pub mod secret; diff --git a/crates/stackable-operator/src/constants/secret.rs b/crates/stackable-operator/src/constants/secret.rs new file mode 100644 index 000000000..d728e5e3a --- /dev/null +++ b/crates/stackable-operator/src/constants/secret.rs @@ -0,0 +1 @@ +pub(crate) const SECRET_BASE_PATH: &str = "/stackable/secrets"; diff --git a/crates/stackable-operator/src/crd/authentication/core/mod.rs b/crates/stackable-operator/src/crd/authentication/core/mod.rs new file mode 100644 index 000000000..35ec3dca1 --- /dev/null +++ b/crates/stackable-operator/src/crd/authentication/core/mod.rs @@ -0,0 +1,141 @@ +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use stackable_versioned::versioned; + +mod v1alpha1_impl; + +#[versioned(version(name = "v1alpha1"))] +pub mod versioned { + // This makes v1alpha1 versions of all authentication providers available to the + // AuthenticationClassProvider enum below. + mod v1alpha1 { + use crate::crd::authentication::{kerberos, ldap, oidc, r#static, tls}; + } + /// The Stackable Platform uses the AuthenticationClass as a central mechanism to handle user + /// authentication across supported products. + /// + /// The authentication mechanism needs to be configured only in the AuthenticationClass which is + /// then referenced in the product. Multiple different authentication providers are supported. + /// Learn more in the [authentication concept documentation][1] and the + /// [Authentication with OpenLDAP tutorial][2]. + /// + /// [1]: DOCS_BASE_URL_PLACEHOLDER/concepts/authentication + /// [2]: DOCS_BASE_URL_PLACEHOLDER/tutorials/authentication_with_openldap + #[versioned(k8s( + group = "authentication.stackable.tech", + plural = "authenticationclasses", + crates( + kube_core = "kube::core", + k8s_openapi = "k8s_openapi", + schemars = "schemars" + ) + ))] + #[derive( + Clone, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, + CustomResource, + Deserialize, + JsonSchema, + Serialize, + )] + #[serde(rename_all = "camelCase")] + pub struct AuthenticationClassSpec { + /// Provider used for authentication like LDAP or Kerberos. + pub provider: AuthenticationClassProvider, + } + + #[derive( + Clone, + Debug, + Deserialize, + strum::Display, + Eq, + Hash, + JsonSchema, + Ord, + PartialEq, + PartialOrd, + Serialize, + )] + #[serde(rename_all = "camelCase")] + #[allow(clippy::large_enum_variant)] + pub enum AuthenticationClassProvider { + /// The [static provider](https://DOCS_BASE_URL_PLACEHOLDER/concepts/authentication#_static) + /// is used to configure a static set of users, identified by username and password. + Static(r#static::v1alpha1::AuthenticationProvider), + + /// The [LDAP provider](DOCS_BASE_URL_PLACEHOLDER/concepts/authentication#_ldap). + /// There is also the ["Authentication with LDAP" tutorial](DOCS_BASE_URL_PLACEHOLDER/tutorials/authentication_with_openldap) + /// where you can learn to configure Superset and Trino with OpenLDAP. + Ldap(ldap::v1alpha1::AuthenticationProvider), + + /// The OIDC provider can be used to configure OpenID Connect. + Oidc(oidc::v1alpha1::AuthenticationProvider), + + /// The [TLS provider](DOCS_BASE_URL_PLACEHOLDER/concepts/authentication#_tls). + /// The TLS AuthenticationClass is used when users should authenticate themselves with a TLS certificate. + Tls(tls::v1alpha1::AuthenticationProvider), + + /// The [Kerberos provider](DOCS_BASE_URL_PLACEHOLDER/concepts/authentication#_kerberos). + /// The Kerberos AuthenticationClass is used when users should authenticate themselves via Kerberos. + Kerberos(kerberos::v1alpha1::AuthenticationProvider), + } + + /// Common [`v1alpha1::ClientAuthenticationDetails`] which is specified at the client/ product + /// cluster level. It provides a name (key) to resolve a particular [`AuthenticationClass`]. + /// Additionally, it provides authentication provider specific configuration (OIDC and LDAP for + /// example). + /// + /// If the product needs additional (product specific) authentication options, it is recommended + /// to wrap this struct and use `#[serde(flatten)]` on the field. + /// + /// Additionally, it might be the case that special fields are needed in the contained structs, + /// such as [`oidc::v1alpha1::ClientAuthenticationOptions`]. To be able to add custom fields in + /// that structs without serde(flattening) multiple structs, they are generic, so you can add + /// additional attributes if needed. + /// + /// ### Example + /// + /// ``` + /// # use schemars::JsonSchema; + /// # use serde::{Deserialize, Serialize}; + /// use stackable_operator::crd::authentication::core::v1alpha1; + /// + /// #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] + /// #[serde(rename_all = "camelCase")] + /// pub struct SupersetAuthenticationClass { + /// pub user_registration_role: String, + /// pub user_registration: bool, + /// + /// #[serde(flatten)] + /// pub common: v1alpha1::ClientAuthenticationDetails, + /// } + /// ``` + #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] + #[serde(rename_all = "camelCase")] + #[schemars(description = "")] + pub struct ClientAuthenticationDetails { + /// Name of the [AuthenticationClass](https://docs.stackable.tech/home/nightly/concepts/authentication) used to + /// authenticate users. + // + // To get the concrete [`AuthenticationClass`], we must resolve it. This resolution can be achieved by using + // [`ClientAuthenticationDetails::resolve_class`]. + #[serde(rename = "authenticationClass")] + authentication_class_ref: String, + + /// This field contains OIDC-specific configuration. It is only required in case OIDC is used. + // + // Use [`ClientAuthenticationDetails::oidc_or_error`] to get the value or report an error to the user. + // TODO: Ideally we want this to be an enum once other `ClientAuthenticationOptions` are added, so + // that user can not configure multiple options at the same time (yes we are aware that this makes a + // changing the type of an AuthenticationClass harder). + // This is a non-breaking change though :) + oidc: Option>, + } +} diff --git a/crates/stackable-operator/src/crd/authentication/core/v1alpha1_impl.rs b/crates/stackable-operator/src/crd/authentication/core/v1alpha1_impl.rs new file mode 100644 index 000000000..13f1eb922 --- /dev/null +++ b/crates/stackable-operator/src/crd/authentication/core/v1alpha1_impl.rs @@ -0,0 +1,82 @@ +use snafu::{OptionExt as _, Snafu}; + +use crate::{ + client::Client, + crd::authentication::{ + core::v1alpha1::{AuthenticationClass, ClientAuthenticationDetails}, + oidc::v1alpha1 as oidc_v1alpha1, + }, +}; + +type Result = std::result::Result; + +// NOTE (@Techassi): Where is the best place to put this? +#[derive(Debug, PartialEq, Snafu)] +pub enum Error { + #[snafu(display( + "authentication details for OIDC were not specified. The AuthenticationClass {auth_class_name:?} uses an OIDC provider, you need to specify OIDC authentication details (such as client credentials) as well" + ))] + OidcAuthenticationDetailsNotSpecified { auth_class_name: String }, +} + +impl AuthenticationClass { + pub async fn resolve( + client: &Client, + authentication_class_name: &str, + ) -> crate::client::Result { + client + .get::(authentication_class_name, &()) // AuthenticationClass has ClusterScope + .await + } +} + +impl ClientAuthenticationDetails { + /// Resolves this specific [`AuthenticationClass`]. Usually products support + /// a list of authentication classes, which individually need to be resolved.crate::client + pub async fn resolve_class( + &self, + client: &Client, + ) -> crate::client::Result { + AuthenticationClass::resolve(client, &self.authentication_class_ref).await + } + + pub fn authentication_class_name(&self) -> &String { + &self.authentication_class_ref + } + + /// In case OIDC is configured, the user *needs* to provide some connection details, + /// such as the client credentials. Call this function in case the user has configured + /// OIDC, as it will error out then the OIDC client details are missing. + pub fn oidc_or_error( + &self, + auth_class_name: &str, + ) -> Result<&oidc_v1alpha1::ClientAuthenticationOptions> { + self.oidc + .as_ref() + .with_context(|| OidcAuthenticationDetailsNotSpecifiedSnafu { + auth_class_name: auth_class_name.to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use crate::crd::authentication::{ + core::v1alpha1::AuthenticationClassProvider, kerberos::v1alpha1 as kerberos_v1alpha1, + tls::v1alpha1 as tls_v1alpha1, + }; + + #[test] + fn provider_to_string() { + let tls_provider = AuthenticationClassProvider::Tls(tls_v1alpha1::AuthenticationProvider { + client_cert_secret_class: None, + }); + assert_eq!("Tls", tls_provider.to_string()); + + let kerberos_provider = + AuthenticationClassProvider::Kerberos(kerberos_v1alpha1::AuthenticationProvider { + kerberos_secret_class: "kerberos".to_string(), + }); + assert_eq!("Kerberos", kerberos_provider.to_string()); + } +} diff --git a/crates/stackable-operator/src/commons/authentication/kerberos.rs b/crates/stackable-operator/src/crd/authentication/kerberos/mod.rs similarity index 81% rename from crates/stackable-operator/src/commons/authentication/kerberos.rs rename to crates/stackable-operator/src/crd/authentication/kerberos/mod.rs index 5b4ffe893..afff0b374 100644 --- a/crates/stackable-operator/src/commons/authentication/kerberos.rs +++ b/crates/stackable-operator/src/crd/authentication/kerberos/mod.rs @@ -1,6 +1,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use stackable_versioned::versioned; +#[versioned(version(name = "v1alpha1"))] #[derive( Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, )] diff --git a/crates/stackable-operator/src/crd/authentication/ldap/mod.rs b/crates/stackable-operator/src/crd/authentication/ldap/mod.rs new file mode 100644 index 000000000..114196a7c --- /dev/null +++ b/crates/stackable-operator/src/crd/authentication/ldap/mod.rs @@ -0,0 +1,69 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use stackable_versioned::versioned; + +use crate::commons::{ + networking::HostName, secret_class::SecretClassVolume, tls_verification::TlsClientDetails, +}; + +mod v1alpha1_impl; + +#[versioned(version(name = "v1alpha1"))] +pub mod versioned { + #[derive( + Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, + )] + #[serde(rename_all = "camelCase")] + pub struct AuthenticationProvider { + /// Host of the LDAP server, for example: `my.ldap.server` or `127.0.0.1`. + pub hostname: HostName, + + /// Port of the LDAP server. If TLS is used defaults to 636 otherwise to 389. + port: Option, + + /// LDAP search base, for example: `ou=users,dc=example,dc=org`. + #[serde(default)] + pub search_base: String, + + /// LDAP query to filter users, for example: `(memberOf=cn=myTeam,ou=teams,dc=example,dc=org)`. + #[serde(default)] + pub search_filter: String, + + /// The name of the LDAP object fields. + #[serde(default)] + pub ldap_field_names: FieldNames, + + /// In case you need a special account for searching the LDAP server you can specify it here. + bind_credentials: Option, + + /// Use a TLS connection. If not specified no TLS will be used. + #[serde(flatten)] + pub tls: TlsClientDetails, + } + + #[derive( + Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, + )] + #[serde(rename_all = "camelCase")] + pub struct FieldNames { + /// The name of the username field + #[serde(default = "FieldNames::default_uid")] + pub uid: String, + + /// The name of the group field + #[serde(default = "FieldNames::default_group")] + pub group: String, + + /// The name of the firstname field + #[serde(default = "FieldNames::default_given_name")] + pub given_name: String, + + /// The name of the lastname field + #[serde(default = "FieldNames::default_surname")] + pub surname: String, + + /// The name of the email field + #[serde(default = "FieldNames::default_email")] + pub email: String, + } +} diff --git a/crates/stackable-operator/src/commons/authentication/ldap.rs b/crates/stackable-operator/src/crd/authentication/ldap/v1alpha1_impl.rs similarity index 78% rename from crates/stackable-operator/src/commons/authentication/ldap.rs rename to crates/stackable-operator/src/crd/authentication/ldap/v1alpha1_impl.rs index 676c27d7b..18765d360 100644 --- a/crates/stackable-operator/src/commons/authentication/ldap.rs +++ b/crates/stackable-operator/src/crd/authentication/ldap/v1alpha1_impl.rs @@ -1,7 +1,5 @@ use k8s_openapi::api::core::v1::{Volume, VolumeMount}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use snafu::{ResultExt, Snafu}; +use snafu::{ResultExt as _, Snafu}; use url::{ParseError, Url}; use crate::{ @@ -9,12 +7,9 @@ use crate::{ self, pod::{PodBuilder, container::ContainerBuilder, volume::VolumeMountBuilder}, }, - commons::{ - authentication::SECRET_BASE_PATH, - networking::HostName, - secret_class::{SecretClassVolume, SecretClassVolumeError}, - tls_verification::{TlsClientDetails, TlsClientDetailsError}, - }, + commons::{secret_class::SecretClassVolumeError, tls_verification::TlsClientDetailsError}, + constants::secret::SECRET_BASE_PATH, + crd::authentication::ldap::v1alpha1::{AuthenticationProvider, FieldNames}, }; pub type Result = std::result::Result; @@ -41,37 +36,6 @@ pub enum Error { }, } -#[derive( - Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, -)] -#[serde(rename_all = "camelCase")] -pub struct AuthenticationProvider { - /// Host of the LDAP server, for example: `my.ldap.server` or `127.0.0.1`. - pub hostname: HostName, - - /// Port of the LDAP server. If TLS is used defaults to 636 otherwise to 389. - port: Option, - - /// LDAP search base, for example: `ou=users,dc=example,dc=org`. - #[serde(default)] - pub search_base: String, - - /// LDAP query to filter users, for example: `(memberOf=cn=myTeam,ou=teams,dc=example,dc=org)`. - #[serde(default)] - pub search_filter: String, - - /// The name of the LDAP object fields. - #[serde(default)] - pub ldap_field_names: FieldNames, - - /// In case you need a special account for searching the LDAP server you can specify it here. - bind_credentials: Option, - - /// Use a TLS connection. If not specified no TLS will be used. - #[serde(flatten)] - pub tls: TlsClientDetails, -} - impl AuthenticationProvider { /// Returns the LDAP endpoint [`Url`]. pub fn endpoint_url(&self) -> Result { @@ -168,46 +132,24 @@ impl AuthenticationProvider { } } -#[derive( - Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, -)] -#[serde(rename_all = "camelCase")] -pub struct FieldNames { - /// The name of the username field - #[serde(default = "FieldNames::default_uid")] - pub uid: String, - /// The name of the group field - #[serde(default = "FieldNames::default_group")] - pub group: String, - /// The name of the firstname field - #[serde(default = "FieldNames::default_given_name")] - pub given_name: String, - /// The name of the lastname field - #[serde(default = "FieldNames::default_surname")] - pub surname: String, - /// The name of the email field - #[serde(default = "FieldNames::default_email")] - pub email: String, -} - impl FieldNames { - fn default_uid() -> String { + pub(super) fn default_uid() -> String { "uid".to_string() } - fn default_group() -> String { + pub(super) fn default_group() -> String { "memberof".to_string() } - fn default_given_name() -> String { + pub(super) fn default_given_name() -> String { "givenName".to_string() } - fn default_surname() -> String { + pub(super) fn default_surname() -> String { "sn".to_string() } - fn default_email() -> String { + pub(super) fn default_email() -> String { "mail".to_string() } } @@ -227,6 +169,7 @@ impl Default for FieldNames { #[cfg(test)] mod tests { use super::*; + use crate::commons::secret_class::SecretClassVolume; #[test] fn minimal() { diff --git a/crates/stackable-operator/src/crd/authentication/mod.rs b/crates/stackable-operator/src/crd/authentication/mod.rs new file mode 100644 index 000000000..1e2a61316 --- /dev/null +++ b/crates/stackable-operator/src/crd/authentication/mod.rs @@ -0,0 +1,6 @@ +pub mod core; +pub mod kerberos; +pub mod ldap; +pub mod oidc; +pub mod r#static; +pub mod tls; diff --git a/crates/stackable-operator/src/crd/authentication/oidc/mod.rs b/crates/stackable-operator/src/crd/authentication/oidc/mod.rs new file mode 100644 index 000000000..3885ba0cf --- /dev/null +++ b/crates/stackable-operator/src/crd/authentication/oidc/mod.rs @@ -0,0 +1,109 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use stackable_versioned::versioned; +#[cfg(doc)] +use url::Url; + +use crate::commons::{networking::HostName, tls_verification::TlsClientDetails}; + +mod v1alpha1_impl; + +// FIXME (@Techassi): These constants should also be versioned +pub const CLIENT_ID_SECRET_KEY: &str = "clientId"; +pub const CLIENT_SECRET_SECRET_KEY: &str = "clientSecret"; + +/// Do *not* use this for [`Url::join`], as the leading slash will erase the existing path! +const DEFAULT_WELLKNOWN_OIDC_CONFIG_PATH: &str = "/.well-known/openid-configuration"; + +#[versioned(version(name = "v1alpha1"))] +pub mod versioned { + /// This struct contains configuration values to configure an OpenID Connect + /// (OIDC) authentication class. Required fields are the identity provider + /// (IdP) `hostname` and the TLS configuration. The `port` is selected + /// automatically if not configured otherwise. The `rootPath` defaults + /// to `/`. + #[derive( + Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, + )] + #[serde(rename_all = "camelCase")] + pub struct AuthenticationProvider { + /// Host of the identity provider, e.g. `my.keycloak.corp` or `127.0.0.1`. + hostname: HostName, + + /// Port of the identity provider. If TLS is used defaults to 443, + /// otherwise to 80. + port: Option, + + /// Root HTTP path of the identity provider. Defaults to `/`. + #[serde(default = "v1alpha1::AuthenticationProvider::default_root_path")] + root_path: String, + + /// Use a TLS connection. If not specified no TLS will be used. + #[serde(flatten)] + pub tls: TlsClientDetails, + + /// If a product extracts some sort of "effective user" that is represented by a + /// string internally, this config determines with claim is used to extract that + /// string. It is desirable to use `sub` in here (or some other stable identifier), + /// but in many cases you might need to use `preferred_username` (e.g. in case of Keycloak) + /// or a different claim instead. + /// + /// Please note that some products hard-coded the claim in their implementation, + /// so some product operators might error out if the product hardcodes a different + /// claim than configured here. + /// + /// We don't provide any default value, as there is no correct way of doing it + /// that works in all setups. Most demos will probably use `preferred_username`, + /// although `sub` being more desirable, but technically impossible with the current + /// behavior of the products. + pub principal_claim: String, + + /// Scopes to request from your identity provider. It is recommended to + /// request the `openid`, `email`, and `profile` scopes. + pub scopes: Vec, + + /// This is a hint about which identity provider is used by the + /// AuthenticationClass. Operators *can* opt to use this + /// value to enable known quirks around OIDC / OAuth authentication. + /// Not providing a hint means there is no hint and OIDC should be used as it is + /// intended to be used (via the `.well-known` discovery). + #[serde(default)] + pub provider_hint: Option, + } + + /// An enum of supported OIDC or identity providers which can serve as a hint + /// in the product operator. Some products require special handling of + /// authentication related config options. This hint can be used to enable such + /// special handling. + #[derive( + Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, + )] + #[serde(rename_all = "PascalCase")] + pub enum IdentityProviderHint { + Keycloak, + } + + /// OIDC specific config options. These are set on the product config level. + #[derive( + Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, + )] + #[serde(rename_all = "camelCase")] + pub struct ClientAuthenticationOptions { + /// A reference to the OIDC client credentials secret. The secret contains + /// the client id and secret. + #[serde(rename = "clientCredentialsSecret")] + pub client_credentials_secret_ref: String, + + /// An optional list of extra scopes which get merged with the scopes + /// defined in the [`AuthenticationClass`][1]. + /// + /// [1]: crate::crd::authentication::core::v1alpha1::AuthenticationClass + #[serde(default)] + pub extra_scopes: Vec, + + // If desired, operators can add custom fields that are only needed for this specific product. + // They need to create a struct holding them and pass that as `T`. + #[serde(flatten)] + pub product_specific_fields: T, + } +} diff --git a/crates/stackable-operator/src/commons/authentication/oidc.rs b/crates/stackable-operator/src/crd/authentication/oidc/v1alpha1_impl.rs similarity index 73% rename from crates/stackable-operator/src/commons/authentication/oidc.rs rename to crates/stackable-operator/src/crd/authentication/oidc/v1alpha1_impl.rs index ed19eebdb..6dd9a77c5 100644 --- a/crates/stackable-operator/src/commons/authentication/oidc.rs +++ b/crates/stackable-operator/src/crd/authentication/oidc/v1alpha1_impl.rs @@ -4,25 +4,21 @@ use std::{ }; use k8s_openapi::api::core::v1::{EnvVar, EnvVarSource, SecretKeySelector}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use snafu::{ResultExt, Snafu}; +use snafu::{ResultExt as _, Snafu}; use url::{ParseError, Url}; -#[cfg(doc)] -use crate::commons::authentication::AuthenticationClass; -use crate::commons::{ - authentication::SECRET_BASE_PATH, networking::HostName, tls_verification::TlsClientDetails, +use crate::{ + commons::{networking::HostName, tls_verification::TlsClientDetails}, + constants::secret::SECRET_BASE_PATH, + crd::authentication::oidc::{ + CLIENT_ID_SECRET_KEY, CLIENT_SECRET_SECRET_KEY, DEFAULT_WELLKNOWN_OIDC_CONFIG_PATH, + v1alpha1::{AuthenticationProvider, IdentityProviderHint}, + }, }; pub type Result = std::result::Result; -pub const CLIENT_ID_SECRET_KEY: &str = "clientId"; -pub const CLIENT_SECRET_SECRET_KEY: &str = "clientSecret"; - -/// Do *not* use this for [`Url::join`], as the leading slash will erase the existing path! -const DEFAULT_WELLKNOWN_OIDC_CONFIG_PATH: &str = "/.well-known/openid-configuration"; - +// TODO (@Techassi): Move this into mod.rs #[derive(Debug, PartialEq, Snafu)] pub enum Error { #[snafu(display("failed to parse OIDC endpoint url"))] @@ -34,64 +30,6 @@ pub enum Error { SetOidcEndpointScheme { endpoint: Url, scheme: String }, } -/// This struct contains configuration values to configure an OpenID Connect -/// (OIDC) authentication class. Required fields are the identity provider -/// (IdP) `hostname` and the TLS configuration. The `port` is selected -/// automatically if not configured otherwise. The `rootPath` defaults -/// to `/`. -#[derive( - Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, -)] -#[serde(rename_all = "camelCase")] -pub struct AuthenticationProvider { - /// Host of the identity provider, e.g. `my.keycloak.corp` or `127.0.0.1`. - hostname: HostName, - - /// Port of the identity provider. If TLS is used defaults to 443, - /// otherwise to 80. - port: Option, - - /// Root HTTP path of the identity provider. Defaults to `/`. - #[serde(default = "default_root_path")] - root_path: String, - - /// Use a TLS connection. If not specified no TLS will be used. - #[serde(flatten)] - pub tls: TlsClientDetails, - - /// If a product extracts some sort of "effective user" that is represented by a - /// string internally, this config determines with claim is used to extract that - /// string. It is desirable to use `sub` in here (or some other stable identifier), - /// but in many cases you might need to use `preferred_username` (e.g. in case of Keycloak) - /// or a different claim instead. - /// - /// Please note that some products hard-coded the claim in their implementation, - /// so some product operators might error out if the product hardcodes a different - /// claim than configured here. - /// - /// We don't provide any default value, as there is no correct way of doing it - /// that works in all setups. Most demos will probably use `preferred_username`, - /// although `sub` being more desirable, but technically impossible with the current - /// behavior of the products. - pub principal_claim: String, - - /// Scopes to request from your identity provider. It is recommended to - /// request the `openid`, `email`, and `profile` scopes. - pub scopes: Vec, - - /// This is a hint about which identity provider is used by the - /// AuthenticationClass. Operators *can* opt to use this - /// value to enable known quirks around OIDC / OAuth authentication. - /// Not providing a hint means there is no hint and OIDC should be used as it is - /// intended to be used (via the `.well-known` discovery). - #[serde(default)] - pub provider_hint: Option, -} - -fn default_root_path() -> String { - "/".to_string() -} - impl AuthenticationProvider { pub fn new( hostname: HostName, @@ -240,40 +178,10 @@ impl AuthenticationProvider { }, ] } -} -/// An enum of supported OIDC or identity providers which can serve as a hint -/// in the product operator. Some products require special handling of -/// authentication related config options. This hint can be used to enable such -/// special handling. -#[derive( - Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, -)] -#[serde(rename_all = "PascalCase")] -pub enum IdentityProviderHint { - Keycloak, -} - -/// OIDC specific config options. These are set on the product config level. -#[derive( - Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, -)] -#[serde(rename_all = "camelCase")] -pub struct ClientAuthenticationOptions { - /// A reference to the OIDC client credentials secret. The secret contains - /// the client id and secret. - #[serde(rename = "clientCredentialsSecret")] - pub client_credentials_secret_ref: String, - - /// An optional list of extra scopes which get merged with the scopes - /// defined in the [`AuthenticationClass`]. - #[serde(default)] - pub extra_scopes: Vec, - - // If desired, operators can add custom fields that are only needed for this specific product. - // They need to create a struct holding them and pass that as `T`. - #[serde(flatten)] - pub product_specific_fields: T, + pub(super) fn default_root_path() -> String { + "/".to_string() + } } #[cfg(test)] diff --git a/crates/stackable-operator/src/commons/authentication/static_.rs b/crates/stackable-operator/src/crd/authentication/static/mod.rs similarity index 69% rename from crates/stackable-operator/src/commons/authentication/static_.rs rename to crates/stackable-operator/src/crd/authentication/static/mod.rs index 65b485420..aa53b9dbb 100644 --- a/crates/stackable-operator/src/commons/authentication/static_.rs +++ b/crates/stackable-operator/src/crd/authentication/static/mod.rs @@ -19,25 +19,30 @@ //! periodically converts the secret contents to the required product format. //! //! See + use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use stackable_versioned::versioned; -#[derive( - Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, -)] -#[serde(rename_all = "camelCase")] -pub struct AuthenticationProvider { - /// Secret providing the usernames and passwords. - /// The Secret must contain an entry for every user, with the key being the username and the value the password in plain text. - /// It must be located in the same namespace as the product using it. - pub user_credentials_secret: UserCredentialsSecretRef, -} +#[versioned(version(name = "v1alpha1"))] +pub mod versioned { + #[derive( + Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, + )] + #[serde(rename_all = "camelCase")] + pub struct AuthenticationProvider { + /// Secret providing the usernames and passwords. + /// The Secret must contain an entry for every user, with the key being the username and the value the password in plain text. + /// It must be located in the same namespace as the product using it. + pub user_credentials_secret: UserCredentialsSecretRef, + } -#[derive( - Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, -)] -#[serde(rename_all = "camelCase")] -pub struct UserCredentialsSecretRef { - /// Name of the Secret. - pub name: String, + #[derive( + Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, + )] + #[serde(rename_all = "camelCase")] + pub struct UserCredentialsSecretRef { + /// Name of the Secret. + pub name: String, + } } diff --git a/crates/stackable-operator/src/commons/authentication/tls.rs b/crates/stackable-operator/src/crd/authentication/tls/mod.rs similarity index 89% rename from crates/stackable-operator/src/commons/authentication/tls.rs rename to crates/stackable-operator/src/crd/authentication/tls/mod.rs index 202e49a10..38bdcb633 100644 --- a/crates/stackable-operator/src/commons/authentication/tls.rs +++ b/crates/stackable-operator/src/crd/authentication/tls/mod.rs @@ -1,6 +1,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use stackable_versioned::versioned; +#[versioned(version(name = "v1alpha1"))] #[derive( Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, )] diff --git a/crates/stackable-operator/src/crd/listener/class/mod.rs b/crates/stackable-operator/src/crd/listener/class/mod.rs new file mode 100644 index 000000000..a520ff087 --- /dev/null +++ b/crates/stackable-operator/src/crd/listener/class/mod.rs @@ -0,0 +1,55 @@ +//! This module contains resource types to interact with [`v1alpha1::ListenerClass`]es. +//! +//! It declares a policy for how [`v1alpha1::Listener`][listener]s are exposed to users. It is +//! created by the cluster administrator. +//! +//! [listener]: crate::crd::listener::listeners::v1alpha1::Listener + +use std::collections::BTreeMap; + +#[cfg(doc)] +use k8s_openapi::api::core::v1::Service; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use stackable_versioned::versioned; + +use crate::crd::listener::core::v1alpha1 as core_v1alpha1; +#[cfg(doc)] +use crate::crd::listener::listeners::v1alpha1::Listener; + +mod v1alpha1_impl; + +#[versioned(version(name = "v1alpha1"))] +pub mod versioned { + /// Defines a policy for how [Listeners](DOCS_BASE_URL_PLACEHOLDER/listener-operator/listener) should be exposed. + /// Read the [ListenerClass documentation](DOCS_BASE_URL_PLACEHOLDER/listener-operator/listenerclass) + /// for more information. + #[versioned(k8s(group = "listeners.stackable.tech"))] + #[derive(CustomResource, Serialize, Deserialize, Clone, Debug, JsonSchema, PartialEq, Eq)] + #[serde(rename_all = "camelCase")] + pub struct ListenerClassSpec { + pub service_type: core_v1alpha1::ServiceType, + + /// Annotations that should be added to the Service object. + #[serde(default)] + pub service_annotations: BTreeMap, + + /// `externalTrafficPolicy` that should be set on the created [`Service`] objects. + /// + /// The default is `Local` (in contrast to `Cluster`), as we aim to direct traffic to a node running the workload + /// and we should keep testing that as the primary configuration. Cluster is a fallback option for providers that + /// break Local mode (IONOS so far). + #[serde(default = "ListenerClassSpec::default_service_external_traffic_policy")] + pub service_external_traffic_policy: core_v1alpha1::KubernetesTrafficPolicy, + + /// Whether addresses should prefer using the IP address (`IP`) or the hostname (`Hostname`). + /// Can also be set to `HostnameConservative`, which will use `IP` for `NodePort` service types, but `Hostname` for everything else. + /// + /// The other type will be used if the preferred type is not available. + /// + /// Defaults to `HostnameConservative`. + #[serde(default = "ListenerClassSpec::default_preferred_address_type")] + pub preferred_address_type: core_v1alpha1::PreferredAddressType, + } +} diff --git a/crates/stackable-operator/src/crd/listener/class/v1alpha1_impl.rs b/crates/stackable-operator/src/crd/listener/class/v1alpha1_impl.rs new file mode 100644 index 000000000..56cb57780 --- /dev/null +++ b/crates/stackable-operator/src/crd/listener/class/v1alpha1_impl.rs @@ -0,0 +1,19 @@ +use crate::crd::listener::{ + class::v1alpha1::ListenerClassSpec, + core::v1alpha1::{AddressType, KubernetesTrafficPolicy, PreferredAddressType}, +}; + +impl ListenerClassSpec { + pub(super) const fn default_service_external_traffic_policy() -> KubernetesTrafficPolicy { + KubernetesTrafficPolicy::Local + } + + pub(super) const fn default_preferred_address_type() -> PreferredAddressType { + PreferredAddressType::HostnameConservative + } + + /// Resolves [`Self::preferred_address_type`]'s "smart" modes depending on the rest of `self`. + pub fn resolve_preferred_address_type(&self) -> AddressType { + self.preferred_address_type.resolve(self) + } +} diff --git a/crates/stackable-operator/src/crd/listener/core/mod.rs b/crates/stackable-operator/src/crd/listener/core/mod.rs new file mode 100644 index 000000000..394aae48d --- /dev/null +++ b/crates/stackable-operator/src/crd/listener/core/mod.rs @@ -0,0 +1,83 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use stackable_versioned::versioned; + +#[cfg(doc)] +use crate::crd::listener::class::v1alpha1::ListenerClass; + +mod v1alpha1_impl; + +#[versioned(version(name = "v1alpha1"))] +pub mod versioned { + /// The method used to access the services. + // + // Please note that this does not necessarily need to be restricted to the same Service types + // Kubernetes supports. Listeners currently happens to support the same set of service types as + // upstream Kubernetes, but we still want to have the freedom to add custom ones in the future + // (for example: Istio ingress?). + #[derive(Serialize, Deserialize, Clone, Copy, Debug, JsonSchema, PartialEq, Eq)] + pub enum ServiceType { + /// Reserve a port on each node. + NodePort, + + /// Provision a dedicated load balancer. + LoadBalancer, + + /// Assigns an IP address from a pool of IP addresses that your cluster has reserved for + /// that purpose. + ClusterIP, + } + + /// Service Internal Traffic Policy enables internal traffic restrictions to only route internal + /// traffic to endpoints within the node the traffic originated from. The "internal" traffic + /// here refers to traffic originated from Pods in the current cluster. This can help to reduce + /// costs and improve performance. See [Kubernetes docs][k8s-docs]. + /// + /// [k8s-docs]: https://kubernetes.io/docs/concepts/services-networking/service-traffic-policy/ + // + // Please note that this represents a Kubernetes type, so the name of the enum variant needs to + // exactly match the Kubernetes traffic policy. + #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, PartialEq, Eq, strum::Display)] + pub enum KubernetesTrafficPolicy { + /// Obscures the client source IP and may cause a second hop to another node, but allows + /// Kubernetes to spread the load between all nodes. + Cluster, + + /// Preserves the client source IP and avoid a second hop for LoadBalancer and NodePort type + /// Services, but makes clients responsible for spreading the load. + Local, + } + + /// The type of a given address. + #[derive(Serialize, Deserialize, Clone, Copy, Debug, JsonSchema, PartialEq, Eq)] + #[serde(rename_all = "PascalCase")] + pub enum AddressType { + /// A resolvable DNS hostname. + Hostname, + + /// A resolved IP address. + #[serde(rename = "IP")] + Ip, + } + + /// A mode for deciding the preferred [`v1alpha1::AddressType`]. + /// + /// These can vary depending on the rest of the [`ListenerClass`][lc]. + /// + /// [lc]: crate::crd::listener::class::v1alpha1::ListenerClass + #[derive(Serialize, Deserialize, Clone, Copy, Debug, JsonSchema, PartialEq, Eq)] + pub enum PreferredAddressType { + /// Like [`AddressType::Hostname`], but prefers [`AddressType::Ip`] for [`ServiceType::NodePort`], since their hostnames are less likely to be resolvable. + HostnameConservative, + + // Like the respective variants of AddressType. Ideally we would refer to them instead of + // copy/pasting, but that breaks due to upstream issues: + // + // - https://github.com/GREsau/schemars/issues/222 + // - https://github.com/kube-rs/kube/issues/1622 + Hostname, + + #[serde(rename = "IP")] + Ip, + } +} diff --git a/crates/stackable-operator/src/crd/listener/core/v1alpha1_impl.rs b/crates/stackable-operator/src/crd/listener/core/v1alpha1_impl.rs new file mode 100644 index 000000000..a6bd85198 --- /dev/null +++ b/crates/stackable-operator/src/crd/listener/core/v1alpha1_impl.rs @@ -0,0 +1,17 @@ +use crate::crd::listener::{ + class::v1alpha1::ListenerClassSpec, + core::v1alpha1::{AddressType, PreferredAddressType, ServiceType}, +}; + +impl PreferredAddressType { + pub fn resolve(self, listener_class: &ListenerClassSpec) -> AddressType { + match self { + PreferredAddressType::HostnameConservative => match listener_class.service_type { + ServiceType::NodePort => AddressType::Ip, + _ => AddressType::Hostname, + }, + PreferredAddressType::Hostname => AddressType::Hostname, + PreferredAddressType::Ip => AddressType::Ip, + } + } +} diff --git a/crates/stackable-operator/src/crd/listener/listeners/mod.rs b/crates/stackable-operator/src/crd/listener/listeners/mod.rs new file mode 100644 index 000000000..095eeb9d0 --- /dev/null +++ b/crates/stackable-operator/src/crd/listener/listeners/mod.rs @@ -0,0 +1,165 @@ +//! This module provides resource types to interact with [`v1alpha1::Listener`]s and +//! [`v1alpha1::PodListener`]s. +//! +//! ## [`v1alpha1::Listener`] +//! +//! Exposes a set of pods, either internally to the cluster or to the outside world. The mechanism +//! for how it is exposed is managed by the [`v1alpha1::ListenerClass`][lc]. +//! +//! It can be either created manually by the application administrator (for applications that expose +//! a single load-balanced endpoint), or automatically when mounting a [listener volume][lvb] (for +//! applications that expose a separate endpoint per replica). +//! +//! All exposed pods *must* have a mounted [listener volume][lvb], regardless of whether the +//! [`v1alpha1::Listener`] is created automatically. +//! +//! ## [`v1alpha1::PodListeners`] +//! +//! Informs users and other operators about the state of all [`v1alpha1::Listener`]s associated with +//! a [`Pod`]. +//! +//! It is created by the Stackable Secret Operator, and always named `pod-{pod.metadata.uid}`. +//! +//! [lc]: crate::crd::listener::class::v1alpha1::ListenerClass +//! [lvb]: ListenerOperatorVolumeSourceBuilder + +use std::collections::BTreeMap; + +#[cfg(doc)] +use k8s_openapi::api::core::v1::Pod; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use stackable_versioned::versioned; + +#[cfg(doc)] +use crate::builder::pod::volume::ListenerOperatorVolumeSourceBuilder; +use crate::crd::listener::core::v1alpha1 as core_v1alpha1; + +mod v1alpha1_impl; + +#[versioned(version(name = "v1alpha1"))] +pub mod versioned { + /// Exposes a set of pods to the outside world. + /// + /// Essentially a Stackable extension of a Kubernetes Service. Compared to a Service, a Listener changes three things: + /// 1. It uses a cluster-level policy object (ListenerClass) to define how exactly the exposure works + /// 2. It has a consistent API for reading back the exposed address(es) of the service + /// 3. The Pod must mount a Volume referring to the Listener, which also allows + /// ["sticky" scheduling](DOCS_BASE_URL_PLACEHOLDER/listener-operator/listener#_sticky_scheduling). + /// + /// Learn more in the [Listener documentation](DOCS_BASE_URL_PLACEHOLDER/listener-operator/listener). + #[versioned(k8s( + group = "listeners.stackable.tech", + status = "v1alpha1::ListenerStatus", + namespaced + ))] + #[derive( + CustomResource, Serialize, Deserialize, Default, Clone, Debug, JsonSchema, PartialEq, Eq, + )] + #[serde(rename_all = "camelCase")] + pub struct ListenerSpec { + /// The name of the [ListenerClass](DOCS_BASE_URL_PLACEHOLDER/listener-operator/listenerclass). + pub class_name: Option, + + /// Extra labels that the Pods must match in order to be exposed. They must _also_ still have a Volume referring to the Listener. + #[serde(default)] + pub extra_pod_selector_labels: BTreeMap, + + /// Ports that should be exposed. + pub ports: Option>, + + /// Whether incoming traffic should also be directed to Pods that are not `Ready`. + #[serde(default = "ListenerSpec::default_publish_not_ready_addresses")] + pub publish_not_ready_addresses: Option, + } + + /// Informs users about Listeners that are bound by a given Pod. + /// + /// This is not expected to be created or modified by users. It will be created by + /// the Stackable Listener Operator when mounting the listener volume, and is always + /// named `pod-{pod.metadata.uid}`. + #[versioned(k8s( + group = "listeners.stackable.tech", + plural = "podlisteners", + namespaced, + ))] + #[derive( + CustomResource, Serialize, Deserialize, Clone, Debug, JsonSchema, Default, PartialEq, Eq, + )] + #[serde(rename_all = "camelCase")] + pub struct PodListenersSpec { + /// All Listeners currently bound by the Pod. + /// + /// Indexed by Volume name (not PersistentVolume or PersistentVolumeClaim). + pub listeners: BTreeMap, + } + + #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, PartialEq, Eq)] + #[serde(rename_all = "camelCase")] + pub struct ListenerPort { + /// The name of the port. + /// + /// The name of each port *must* be unique within a single Listener. + pub name: String, + + /// The port number. + pub port: i32, + + // FIXME (@Techassi): Turn this into an enum + /// The layer-4 protocol (`TCP` or `UDP`). + pub protocol: Option, + } + + /// Informs users about how to reach the Listener. + #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, PartialEq, Eq)] + #[serde(rename_all = "camelCase")] + pub struct ListenerStatus { + /// The backing Kubernetes Service. + pub service_name: Option, + + /// All addresses that the Listener is currently reachable from. + pub ingress_addresses: Option>, + + /// Port mappings for accessing the Listener on each Node that the Pods are currently running on. + /// + /// This is only intended for internal use by listener-operator itself. This will be left unset if using a ListenerClass that does + /// not require Node-local access. + pub node_ports: Option>, + } + + /// One address that a Listener is accessible from. + #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, PartialEq, Eq)] + #[serde(rename_all = "camelCase")] + pub struct ListenerIngress { + /// The hostname or IP address to the Listener. + pub address: String, + + /// The type of address (`Hostname` or `IP`). + pub address_type: core_v1alpha1::AddressType, + + /// Port mapping table. + pub ports: BTreeMap, + } + + #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, PartialEq, Eq)] + #[serde(rename_all = "camelCase")] + pub struct PodListener { + /// `Node` if this address only allows access to Pods hosted on a specific Kubernetes Node, otherwise `Cluster`. + pub scope: PodListenerScope, + + /// Addresses allowing access to this Pod. + /// + /// Compared to `ingress_addresses` on the Listener status, this list is restricted to addresses that can access this Pod. + /// + /// This field is intended to be equivalent to the files mounted into the Listener volume. + pub ingress_addresses: Option>, + } + + #[derive(Serialize, Deserialize, Clone, Copy, Debug, JsonSchema, PartialEq, Eq)] + #[serde(rename_all = "PascalCase")] + pub enum PodListenerScope { + Node, + Cluster, + } +} diff --git a/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs b/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs new file mode 100644 index 000000000..b9351cf32 --- /dev/null +++ b/crates/stackable-operator/src/crd/listener/listeners/v1alpha1_impl.rs @@ -0,0 +1,7 @@ +use crate::crd::listener::listeners::v1alpha1::ListenerSpec; + +impl ListenerSpec { + pub(super) const fn default_publish_not_ready_addresses() -> Option { + Some(true) + } +} diff --git a/crates/stackable-operator/src/crd/listener/mod.rs b/crates/stackable-operator/src/crd/listener/mod.rs new file mode 100644 index 000000000..398ef37e7 --- /dev/null +++ b/crates/stackable-operator/src/crd/listener/mod.rs @@ -0,0 +1,19 @@ +//! This modules provides resource types used to interact with [listener-operator][listener-docs]. +//! +//! [listener-docs]: https://docs.stackable.tech/listener-operator/stable/index.html +//! [lvb]: ListenerOperatorVolumeSourceBuilder + +#[cfg(doc)] +use k8s_openapi::api::core::v1::{Node, PersistentVolume, PersistentVolumeClaim, Pod, Volume}; + +#[cfg(doc)] +use crate::builder::pod::volume::ListenerOperatorVolumeSourceBuilder; + +mod class; +mod core; +mod listeners; + +// Group all v1alpha1 items in one module. +pub mod v1alpha1 { + pub use super::{class::v1alpha1::*, core::v1alpha1::*, listeners::v1alpha1::*}; +} diff --git a/crates/stackable-operator/src/crd.rs b/crates/stackable-operator/src/crd/mod.rs similarity index 98% rename from crates/stackable-operator/src/crd.rs rename to crates/stackable-operator/src/crd/mod.rs index c3c607056..399b5d988 100644 --- a/crates/stackable-operator/src/crd.rs +++ b/crates/stackable-operator/src/crd/mod.rs @@ -4,6 +4,10 @@ use educe::Educe; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +pub mod authentication; +pub mod listener; +pub mod s3; + /// A reference to a product cluster (for example, a `ZookeeperCluster`) /// /// `namespace`'s defaulting only applies when retrieved via [`ClusterRef::namespace_relative_from`] diff --git a/crates/stackable-operator/src/crd/s3/bucket/mod.rs b/crates/stackable-operator/src/crd/s3/bucket/mod.rs new file mode 100644 index 000000000..6b04b6612 --- /dev/null +++ b/crates/stackable-operator/src/crd/s3/bucket/mod.rs @@ -0,0 +1,64 @@ +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use snafu::Snafu; +use stackable_versioned::versioned; + +use crate::crd::s3::{ConnectionError, connection::v1alpha1 as conn_v1alpha1}; + +mod v1alpha1_impl; + +// NOTE (@Techassi): Where should this error be placed? Technically errors can +// change between version, because version-specific impl blocks might need +// different variants or might use a completely different error type. +#[derive(Debug, Snafu)] +pub enum BucketError { + #[snafu(display("failed to retrieve S3 connection '{s3_connection}'"))] + RetrieveS3Connection { + source: crate::client::Error, + s3_connection: String, + }, + + #[snafu(display("failed to resolve S3 connection"))] + ResolveConnection { source: ConnectionError }, +} + +#[versioned(version(name = "v1alpha1"))] +pub mod versioned { + /// S3 bucket specification containing the bucket name and an inlined or referenced connection specification. + /// Learn more on the [S3 concept documentation](DOCS_BASE_URL_PLACEHOLDER/concepts/s3). + #[versioned(k8s( + group = "s3.stackable.tech", + kind = "S3Bucket", + plural = "s3buckets", + crates( + kube_core = "kube::core", + k8s_openapi = "k8s_openapi", + schemars = "schemars" + ), + namespaced + ))] + #[derive(Clone, CustomResource, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct BucketSpec { + /// The name of the S3 bucket. + pub bucket_name: String, + + /// The definition of an S3 connection, either inline or as a reference. + pub connection: conn_v1alpha1::InlineConnectionOrReference, + } + + #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] + #[serde(rename_all = "camelCase")] + // TODO: This probably should be serde(untagged), but this would be a breaking change + pub enum InlineBucketOrReference { + Inline(BucketSpec), + Reference(String), + } + + /// Use this struct in your operator. + pub struct ResolvedBucket { + pub bucket_name: String, + pub connection: conn_v1alpha1::ConnectionSpec, + } +} diff --git a/crates/stackable-operator/src/crd/s3/bucket/v1alpha1_impl.rs b/crates/stackable-operator/src/crd/s3/bucket/v1alpha1_impl.rs new file mode 100644 index 000000000..cdff41443 --- /dev/null +++ b/crates/stackable-operator/src/crd/s3/bucket/v1alpha1_impl.rs @@ -0,0 +1,54 @@ +//! v1alpha1 specific implementations for S3 buckets. + +use snafu::ResultExt as _; + +use crate::{ + client::Client, + crd::s3::bucket::{ + BucketError, ResolveConnectionSnafu, RetrieveS3ConnectionSnafu, + v1alpha1::{InlineBucketOrReference, ResolvedBucket, S3Bucket}, + }, +}; + +impl InlineBucketOrReference { + pub async fn resolve( + self, + client: &Client, + namespace: &str, + ) -> Result { + match self { + Self::Inline(inline) => { + let connection = inline + .connection + .resolve(client, namespace) + .await + .context(ResolveConnectionSnafu)?; + + Ok(ResolvedBucket { + bucket_name: inline.bucket_name, + connection, + }) + } + Self::Reference(reference) => { + let bucket_spec = client + .get::(&reference, namespace) + .await + .context(RetrieveS3ConnectionSnafu { + s3_connection: reference, + })? + .spec; + + let connection = bucket_spec + .connection + .resolve(client, namespace) + .await + .context(ResolveConnectionSnafu)?; + + Ok(ResolvedBucket { + bucket_name: bucket_spec.bucket_name, + connection, + }) + } + } + } +} diff --git a/crates/stackable-operator/src/commons/s3/helpers.rs b/crates/stackable-operator/src/crd/s3/connection/mod.rs similarity index 55% rename from crates/stackable-operator/src/commons/s3/helpers.rs rename to crates/stackable-operator/src/crd/s3/connection/mod.rs index 97efc1919..51137eb0a 100644 --- a/crates/stackable-operator/src/commons/s3/helpers.rs +++ b/crates/stackable-operator/src/crd/s3/connection/mod.rs @@ -1,55 +1,147 @@ -use k8s_openapi::api::core::v1::{Volume, VolumeMount}; +use kube::CustomResource; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use snafu::ResultExt; +use snafu::{ResultExt as _, Snafu}; +use stackable_versioned::versioned; use url::Url; use crate::{ builder::pod::{PodBuilder, container::ContainerBuilder, volume::VolumeMountBuilder}, - client::Client, commons::{ - authentication::SECRET_BASE_PATH, - s3::{ - AddS3CredentialVolumesSnafu, AddS3TlsClientDetailsVolumesSnafu, AddVolumeMountsSnafu, - AddVolumesSnafu, ParseS3EndpointSnafu, RetrieveS3ConnectionSnafu, S3Bucket, - S3BucketSpec, S3Connection, S3ConnectionSpec, S3Error, SetS3EndpointSchemeSnafu, - }, + networking::HostName, + secret_class::{SecretClassVolume, SecretClassVolumeError}, + tls_verification::{TlsClientDetails, TlsClientDetailsError}, }, + constants::secret::SECRET_BASE_PATH, + k8s_openapi::api::core::v1::{Volume, VolumeMount}, }; -#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -// TODO: This probably should be serde(untagged), but this would be a breaking change -pub enum S3ConnectionInlineOrReference { - Inline(S3ConnectionSpec), - Reference(String), +mod v1alpha1_impl; + +// NOTE (@Techassi): Where should this error be placed? Technically errors can +// change between version., because version-specific impl blocks might need +// different variants or might use a completely different error type. +#[derive(Debug, Snafu)] +pub enum ConnectionError { + #[snafu(display("failed to retrieve S3 connection '{s3_connection}'"))] + RetrieveS3Connection { + source: crate::client::Error, + s3_connection: String, + }, + + #[snafu(display("failed to parse S3 endpoint '{endpoint}'"))] + ParseS3Endpoint { + source: url::ParseError, + endpoint: String, + }, + + #[snafu(display("failed to set S3 endpoint scheme '{scheme}' for endpoint '{endpoint}'"))] + SetS3EndpointScheme { endpoint: Url, scheme: String }, + + #[snafu(display("failed to add S3 credential volumes and volume mounts"))] + AddS3CredentialVolumes { source: SecretClassVolumeError }, + + #[snafu(display("failed to add S3 TLS client details volumes and volume mounts"))] + AddS3TlsClientDetailsVolumes { source: TlsClientDetailsError }, + + #[snafu(display("failed to add required volumes"))] + AddVolumes { source: crate::builder::pod::Error }, + + #[snafu(display("failed to add required volumeMounts"))] + AddVolumeMounts { + source: crate::builder::pod::container::Error, + }, } -/// Use this type in you operator! -pub type ResolvedS3Connection = S3ConnectionSpec; - -impl S3ConnectionInlineOrReference { - pub async fn resolve( - self, - client: &Client, - namespace: &str, - ) -> Result { - match self { - Self::Inline(inline) => Ok(inline), - Self::Reference(reference) => Ok(client - .get::(&reference, namespace) - .await - .context(RetrieveS3ConnectionSnafu { - s3_connection: reference, - })? - .spec), - } +#[versioned(version(name = "v1alpha1"))] +pub mod versioned { + /// S3 connection definition as a resource. + /// Learn more on the [S3 concept documentation](DOCS_BASE_URL_PLACEHOLDER/concepts/s3). + #[versioned(k8s( + group = "s3.stackable.tech", + kind = "S3Connection", + plural = "s3connections", + crates( + kube_core = "kube::core", + k8s_openapi = "k8s_openapi", + schemars = "schemars" + ), + namespaced + ))] + #[derive(CustomResource, Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct ConnectionSpec { + /// Host of the S3 server without any protocol or port. For example: `west1.my-cloud.com`. + pub host: HostName, + + /// Port the S3 server listens on. + /// If not specified the product will determine the port to use. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub port: Option, + + /// Bucket region used for signing headers (sigv4). + /// + /// This defaults to `us-east-1` which is compatible with other implementations such as Minio. + /// + /// WARNING: Some products use the Hadoop S3 implementation which falls back to us-east-2. + #[serde(default)] + pub region: Region, + + /// Which access style to use. + /// Defaults to virtual hosted-style as most of the data products out there. + /// Have a look at the [AWS documentation](https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html). + #[serde(default)] + pub access_style: S3AccessStyle, + + /// If the S3 uses authentication you have to specify you S3 credentials. + /// In the most cases a [SecretClass](DOCS_BASE_URL_PLACEHOLDER/secret-operator/secretclass) + /// providing `accessKey` and `secretKey` is sufficient. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credentials: Option, + + /// Use a TLS connection. If not specified no TLS will be used. + #[serde(flatten)] + pub tls: TlsClientDetails, + } + + #[derive( + strum::Display, Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize, + )] + #[strum(serialize_all = "PascalCase")] + pub enum S3AccessStyle { + /// Use path-style access as described in + Path, + + /// Use as virtual hosted-style access as described in + #[default] + VirtualHosted, + } + + /// Set a named S3 Bucket region. + #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct Region { + #[serde(default = "v1alpha1::Region::default_region_name")] + pub name: String, + } + + #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] + #[serde(rename_all = "camelCase")] + // TODO: This probably should be serde(untagged), but this would be a breaking change + pub enum InlineConnectionOrReference { + Inline(ConnectionSpec), + Reference(String), } } -impl ResolvedS3Connection { +// FIXME (@Techassi): This should be versioned as well, but the macro cannot +// handle new-type structs yet. +/// Use this type in you operator! +pub type ResolvedConnection = v1alpha1::ConnectionSpec; + +impl ResolvedConnection { /// Build the endpoint URL from this connection - pub fn endpoint(&self) -> Result { + pub fn endpoint(&self) -> Result { let endpoint = format!( "http://{host}:{port}", host = self.host.as_url_host(), @@ -84,7 +176,7 @@ impl ResolvedS3Connection { &self, pod_builder: &mut PodBuilder, container_builders: Vec<&mut ContainerBuilder>, - ) -> Result<(), S3Error> { + ) -> Result<(), ConnectionError> { let (volumes, mounts) = self.volumes_and_mounts()?; pod_builder.add_volumes(volumes).context(AddVolumesSnafu)?; for cb in container_builders { @@ -97,7 +189,7 @@ impl ResolvedS3Connection { /// It is recommended to use [`Self::add_volumes_and_mounts`], this function returns you the /// volumes and mounts in case you need to add them by yourself. - pub fn volumes_and_mounts(&self) -> Result<(Vec, Vec), S3Error> { + pub fn volumes_and_mounts(&self) -> Result<(Vec, Vec), ConnectionError> { let mut volumes = Vec::new(); let mut mounts = Vec::new(); @@ -140,48 +232,6 @@ impl ResolvedS3Connection { } } -#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -// TODO: This probably should be serde(untagged), but this would be a breaking change -pub enum S3BucketInlineOrReference { - Inline(S3BucketSpec), - Reference(String), -} - -/// Use this struct in your operator. -pub struct ResolvedS3Bucket { - pub bucket_name: String, - pub connection: S3ConnectionSpec, -} - -impl S3BucketInlineOrReference { - pub async fn resolve( - self, - client: &Client, - namespace: &str, - ) -> Result { - match self { - Self::Inline(inline) => Ok(ResolvedS3Bucket { - bucket_name: inline.bucket_name, - connection: inline.connection.resolve(client, namespace).await?, - }), - Self::Reference(reference) => { - let bucket = client - .get::(&reference, namespace) - .await - .context(RetrieveS3ConnectionSnafu { - s3_connection: reference, - })? - .spec; - Ok(ResolvedS3Bucket { - bucket_name: bucket.bucket_name, - connection: bucket.connection.resolve(client, namespace).await?, - }) - } - } - } -} - #[cfg(test)] mod tests { use std::collections::BTreeMap; @@ -195,7 +245,7 @@ mod tests { // We cant test the correct resolve, as we can't mock the k8s API. #[test] fn http_endpoint() { - let s3 = ResolvedS3Connection { + let s3 = ResolvedConnection { host: "minio".parse().unwrap(), port: None, access_style: Default::default(), @@ -212,7 +262,7 @@ mod tests { #[test] fn https_endpoint() { - let s3 = ResolvedS3Connection { + let s3 = ResolvedConnection { host: "s3-eu-central-2.ionoscloud.com".parse().unwrap(), port: None, access_style: Default::default(), @@ -270,7 +320,7 @@ mod tests { #[test] fn https_without_verification() { - let s3 = ResolvedS3Connection { + let s3 = ResolvedConnection { host: "minio".parse().unwrap(), port: Some(1234), access_style: Default::default(), diff --git a/crates/stackable-operator/src/crd/s3/connection/v1alpha1_impl.rs b/crates/stackable-operator/src/crd/s3/connection/v1alpha1_impl.rs new file mode 100644 index 000000000..b446d6601 --- /dev/null +++ b/crates/stackable-operator/src/crd/s3/connection/v1alpha1_impl.rs @@ -0,0 +1,58 @@ +use snafu::ResultExt as _; + +use crate::{ + client::Client, + crd::s3::{ + connection::{ConnectionError, ResolvedConnection, RetrieveS3ConnectionSnafu}, + v1alpha1::{InlineConnectionOrReference, Region, S3Connection}, + }, +}; + +impl Region { + /// Having it as `const &str` as well, so we don't always allocate a [`String`] just for comparisons + pub const DEFAULT_REGION_NAME: &str = "us-east-1"; + + pub(super) fn default_region_name() -> String { + Self::DEFAULT_REGION_NAME.to_string() + } + + /// Returns if the region sticks to the Stackable defaults. + /// + /// Some products don't really support configuring the region. + /// This function can be used to determine if a warning or error should be raised to inform the + /// user of this situation. + pub fn is_default_config(&self) -> bool { + self.name == Self::DEFAULT_REGION_NAME + } +} + +impl Default for Region { + fn default() -> Self { + Self { + name: Self::default_region_name(), + } + } +} + +impl InlineConnectionOrReference { + pub async fn resolve( + self, + client: &Client, + namespace: &str, + ) -> Result { + match self { + Self::Inline(inline) => Ok(inline), + Self::Reference(reference) => { + let connection_spec = client + .get::(&reference, namespace) + .await + .context(RetrieveS3ConnectionSnafu { + s3_connection: reference, + })? + .spec; + + Ok(connection_spec) + } + } + } +} diff --git a/crates/stackable-operator/src/crd/s3/mod.rs b/crates/stackable-operator/src/crd/s3/mod.rs new file mode 100644 index 000000000..625a10621 --- /dev/null +++ b/crates/stackable-operator/src/crd/s3/mod.rs @@ -0,0 +1,11 @@ +mod bucket; +mod connection; + +// Publicly re-export unversioned items, in this case errors. +pub use bucket::BucketError; +pub use connection::ConnectionError; + +// Group all v1alpha1 items in one module. +pub mod v1alpha1 { + pub use super::{bucket::v1alpha1::*, connection::v1alpha1::*}; +} diff --git a/crates/stackable-operator/src/lib.rs b/crates/stackable-operator/src/lib.rs index cd4108ca0..f0ccc5991 100644 --- a/crates/stackable-operator/src/lib.rs +++ b/crates/stackable-operator/src/lib.rs @@ -12,6 +12,7 @@ pub mod client; pub mod cluster_resources; pub mod commons; pub mod config; +pub mod constants; pub mod cpu; pub mod crd; pub mod helm;