diff --git a/Cargo.lock b/Cargo.lock index 4fdbea5b1..35ae6b63f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2926,7 +2926,7 @@ dependencies = [ [[package]] name = "stackable-operator" -version = "0.75.0" +version = "0.76.0" dependencies = [ "chrono", "clap", diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index 279283bb1..83a197b50 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.76.0] - 2024-09-19 + +### Added + +- BREAKING: Add `HostName` type and use it within LDAP and OIDC AuthenticationClass as well as S3Connection ([#863]). + +### Changed + +- BREAKING: The TLS verification struct now resides in the `commons::tls_verification` module, instead of being placed below `commons::authentication::tls` ([#863]). +- BREAKING: Rename the `Hostname` type to `DomainName` to be consistent with RFC 1123 ([#863]). + +### Fixed + +- BREAKING: The fields `bucketName`, `connection` and `host` on `S3BucketSpec`, `InlinedS3BucketSpec` and `S3ConnectionSpec` are now mandatory. Previously operators errored out in case these fields where missing ([#863]). + +[#863]: https://github.com/stackabletech/operator-rs/pull/863 + ## [0.75.0] - 2024-09-19 ### Added diff --git a/crates/stackable-operator/Cargo.toml b/crates/stackable-operator/Cargo.toml index c0c871155..0aaa82e57 100644 --- a/crates/stackable-operator/Cargo.toml +++ b/crates/stackable-operator/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stackable-operator" description = "Stackable Operator Framework" -version = "0.75.0" +version = "0.76.0" authors.workspace = true license.workspace = true edition.workspace = true diff --git a/crates/stackable-operator/src/commons/authentication/ldap.rs b/crates/stackable-operator/src/commons/authentication/ldap.rs index 35370cce9..2b43bbd71 100644 --- a/crates/stackable-operator/src/commons/authentication/ldap.rs +++ b/crates/stackable-operator/src/commons/authentication/ldap.rs @@ -7,11 +7,10 @@ use url::{ParseError, Url}; use crate::{ builder::pod::{container::ContainerBuilder, volume::VolumeMountBuilder, PodBuilder}, commons::{ - authentication::{ - tls::{TlsClientDetails, TlsClientDetailsError}, - SECRET_BASE_PATH, - }, + authentication::SECRET_BASE_PATH, + networking::HostName, secret_class::{SecretClassVolume, SecretClassVolumeError}, + tls_verification::{TlsClientDetails, TlsClientDetailsError}, }, }; @@ -36,8 +35,8 @@ pub enum Error { )] #[serde(rename_all = "camelCase")] pub struct AuthenticationProvider { - /// Hostname of the LDAP server, for example: `my.ldap.server`. - pub hostname: String, + /// 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, diff --git a/crates/stackable-operator/src/commons/authentication/oidc.rs b/crates/stackable-operator/src/commons/authentication/oidc.rs index 94097f05f..da3273d35 100644 --- a/crates/stackable-operator/src/commons/authentication/oidc.rs +++ b/crates/stackable-operator/src/commons/authentication/oidc.rs @@ -11,7 +11,9 @@ use url::{ParseError, Url}; #[cfg(doc)] use crate::commons::authentication::AuthenticationClass; -use crate::commons::authentication::{tls::TlsClientDetails, SECRET_BASE_PATH}; +use crate::commons::{ + authentication::SECRET_BASE_PATH, networking::HostName, tls_verification::TlsClientDetails, +}; pub type Result = std::result::Result; @@ -40,8 +42,8 @@ pub enum Error { )] #[serde(rename_all = "camelCase")] pub struct AuthenticationProvider { - /// Hostname of the identity provider, e.g. `my.keycloak.corp`. - hostname: String, + /// 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. @@ -90,7 +92,7 @@ fn default_root_path() -> String { impl AuthenticationProvider { pub fn new( - hostname: String, + hostname: HostName, port: Option, root_path: String, tls: TlsClientDetails, @@ -113,8 +115,12 @@ impl AuthenticationProvider { /// configuration path, use `url.join()`. This module provides the default /// path at [`DEFAULT_OIDC_WELLKNOWN_PATH`]. pub fn endpoint_url(&self) -> Result { - let mut url = Url::parse(&format!("http://{}:{}", self.hostname, self.port())) - .context(ParseOidcEndpointUrlSnafu)?; + let mut url = Url::parse(&format!( + "http://{host}:{port}", + host = self.hostname.as_url_host(), + port = self.port() + )) + .context(ParseOidcEndpointUrlSnafu)?; if self.tls.uses_tls() { url.set_scheme("https").map_err(|_| { @@ -317,7 +323,7 @@ mod test { fn test_oidc_ipv6_endpoint_url() { let oidc = serde_yaml::from_str::( " - hostname: '[2606:2800:220:1:248:1893:25c8:1946]' + hostname: 2606:2800:220:1:248:1893:25c8:1946 rootPath: my-root-path port: 12345 scopes: [openid] diff --git a/crates/stackable-operator/src/commons/authentication/tls.rs b/crates/stackable-operator/src/commons/authentication/tls.rs index 5f2e515f3..202e49a10 100644 --- a/crates/stackable-operator/src/commons/authentication/tls.rs +++ b/crates/stackable-operator/src/commons/authentication/tls.rs @@ -1,15 +1,5 @@ -use k8s_openapi::api::core::v1::{Volume, VolumeMount}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use snafu::{ResultExt, Snafu}; - -use crate::{ - builder::pod::{container::ContainerBuilder, volume::VolumeMountBuilder, PodBuilder}, - commons::{ - authentication::SECRET_BASE_PATH, - secret_class::{SecretClassVolume, SecretClassVolumeError}, - }, -}; #[derive( Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, @@ -22,148 +12,3 @@ pub struct AuthenticationProvider { /// will be used to provision client certificates. pub client_cert_secret_class: Option, } - -#[derive(Debug, PartialEq, Snafu)] -pub enum TlsClientDetailsError { - #[snafu(display("failed to convert secret class volume into named Kubernetes volume"))] - SecretClassVolume { source: SecretClassVolumeError }, -} - -#[derive( - Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, -)] -#[serde(rename_all = "camelCase")] -pub struct TlsClientDetails { - /// Use a TLS connection. If not specified no TLS will be used. - pub tls: Option, -} - -impl TlsClientDetails { - /// This functions adds - /// - /// * The needed volumes to the PodBuilder - /// * The needed volume_mounts to all the ContainerBuilder in the list (e.g. init + main container) - /// - /// This function will handle - /// - /// * Tls secret class used to verify the cert of the LDAP server - pub fn add_volumes_and_mounts( - &self, - pod_builder: &mut PodBuilder, - container_builders: Vec<&mut ContainerBuilder>, - ) -> Result<(), TlsClientDetailsError> { - let (volumes, mounts) = self.volumes_and_mounts()?; - pod_builder.add_volumes(volumes); - - for cb in container_builders { - cb.add_volume_mounts(mounts.clone()); - } - - Ok(()) - } - - /// 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), TlsClientDetailsError> { - let mut volumes = Vec::new(); - let mut mounts = Vec::new(); - - if let Some(secret_class) = self.tls_ca_cert_secret_class() { - let volume_name = format!("{secret_class}-ca-cert"); - let secret_class_volume = SecretClassVolume::new(secret_class.clone(), None); - let volume = secret_class_volume - .to_volume(&volume_name) - .context(SecretClassVolumeSnafu)?; - - volumes.push(volume); - mounts.push( - VolumeMountBuilder::new(volume_name, format!("{SECRET_BASE_PATH}/{secret_class}")) - .build(), - ); - } - - Ok((volumes, mounts)) - } - - /// Whether TLS is configured - pub const fn uses_tls(&self) -> bool { - self.tls.is_some() - } - - /// Whether TLS verification is configured. Returns `false` if TLS itself isn't configured - pub fn uses_tls_verification(&self) -> bool { - self.tls - .as_ref() - .map(|tls| tls.verification != TlsVerification::None {}) - .unwrap_or_default() - } - - /// Returns the path of the ca.crt that should be used to verify the LDAP server certificate - /// if TLS verification with a CA cert from a SecretClass is configured. - pub fn tls_ca_cert_mount_path(&self) -> Option { - self.tls_ca_cert_secret_class() - .map(|secret_class| format!("{SECRET_BASE_PATH}/{secret_class}/ca.crt")) - } - - /// Extracts the SecretClass that provides the CA cert used to verify the server certificate. - pub(crate) fn tls_ca_cert_secret_class(&self) -> Option { - if let Some(Tls { - verification: - TlsVerification::Server(TlsServerVerification { - ca_cert: CaCert::SecretClass(secret_class), - }), - }) = &self.tls - { - Some(secret_class.to_owned()) - } else { - None - } - } -} - -#[derive( - Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, -)] -#[serde(rename_all = "camelCase")] -pub struct Tls { - /// The verification method used to verify the certificates of the server and/or the client. - pub verification: TlsVerification, -} - -#[derive( - Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, -)] -#[serde(rename_all = "camelCase")] -pub enum TlsVerification { - /// Use TLS but don't verify certificates. - None {}, - - /// Use TLS and a CA certificate to verify the server. - Server(TlsServerVerification), -} - -#[derive( - Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, -)] -#[serde(rename_all = "camelCase")] -pub struct TlsServerVerification { - /// CA cert to verify the server. - pub ca_cert: CaCert, -} - -#[derive( - Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, -)] -#[serde(rename_all = "camelCase")] -pub enum CaCert { - /// Use TLS and the CA certificates trusted by the common web browsers to verify the server. - /// This can be useful when you e.g. use public AWS S3 or other public available services. - WebPki {}, - - /// Name of the [SecretClass](DOCS_BASE_URL_PLACEHOLDER/secret-operator/secretclass) which will provide the CA certificate. - /// Note that a SecretClass does not need to have a key but can also work with just a CA certificate, - /// so if you got provided with a CA cert but don't have access to the key you can still use this method. - SecretClass(String), -} diff --git a/crates/stackable-operator/src/commons/mod.rs b/crates/stackable-operator/src/commons/mod.rs index 7f145616e..2e61dfe23 100644 --- a/crates/stackable-operator/src/commons/mod.rs +++ b/crates/stackable-operator/src/commons/mod.rs @@ -13,3 +13,4 @@ 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/networking.rs b/crates/stackable-operator/src/commons/networking.rs index 7081823b6..19e58c2eb 100644 --- a/crates/stackable-operator/src/commons/networking.rs +++ b/crates/stackable-operator/src/commons/networking.rs @@ -1,37 +1,48 @@ -use std::{fmt::Display, ops::Deref}; +use std::{fmt::Display, net::IpAddr, ops::Deref, str::FromStr}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use snafu::Snafu; use crate::validation; -/// A validated hostname type, for use in CRDs. -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +/// A validated domain name type conforming to RFC 1123, so e.g. not an IP addresses +#[derive( + Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, JsonSchema, +)] #[serde(try_from = "String", into = "String")] -pub struct Hostname(#[validate(regex(path = "validation::RFC_1123_SUBDOMAIN_REGEX"))] String); +pub struct DomainName(#[validate(regex(path = "validation::RFC_1123_SUBDOMAIN_REGEX"))] String); + +impl FromStr for DomainName { + type Err = validation::Errors; + + fn from_str(value: &str) -> Result { + validation::is_rfc_1123_subdomain(value)?; + Ok(DomainName(value.to_owned())) + } +} -impl TryFrom for Hostname { +impl TryFrom for DomainName { type Error = validation::Errors; fn try_from(value: String) -> Result { - validation::is_rfc_1123_subdomain(&value)?; - Ok(Hostname(value)) + value.parse() } } -impl From for String { - fn from(value: Hostname) -> Self { +impl From for String { + fn from(value: DomainName) -> Self { value.0 } } -impl Display for Hostname { +impl Display for DomainName { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } -impl Deref for Hostname { +impl Deref for DomainName { type Target = str; fn deref(&self) -> &Self::Target { @@ -39,6 +50,87 @@ impl Deref for Hostname { } } +#[derive(Debug, Snafu)] +pub enum HostNameParseError { + #[snafu(display( + "the given hostname {hostname:?} is not a valid hostname, which needs to be either a domain name or IP address" + ))] + InvalidHostname { hostname: String }, +} + +/// A validated hostname, which is either a [`DomainName`] or [`IpAddr`]. +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +#[serde(try_from = "String", into = "String")] +pub enum HostName { + IpAddress(IpAddr), + DomainName(DomainName), +} + +impl JsonSchema for HostName { + fn schema_name() -> String { + "HostName".to_owned() + } + + fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + String::json_schema(gen) + } +} + +impl FromStr for HostName { + type Err = HostNameParseError; + + fn from_str(value: &str) -> Result { + if let Ok(ip) = value.parse::() { + return Ok(HostName::IpAddress(ip)); + } + + if let Ok(domain_name) = value.parse() { + return Ok(HostName::DomainName(domain_name)); + }; + + InvalidHostnameSnafu { + hostname: value.to_owned(), + } + .fail() + } +} + +impl TryFrom for HostName { + type Error = HostNameParseError; + + fn try_from(value: String) -> Result { + value.parse() + } +} + +impl From for String { + fn from(value: HostName) -> Self { + value.to_string() + } +} + +impl Display for HostName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HostName::IpAddress(ip) => write!(f, "{ip}"), + HostName::DomainName(domain_name) => write!(f, "{domain_name}"), + } + } +} + +impl HostName { + /// Formats the host in such a way that it can be used in URLs. + pub fn as_url_host(&self) -> String { + match self { + HostName::IpAddress(ip) => match ip { + IpAddr::V4(ip) => ip.to_string(), + IpAddr::V6(ip) => format!("[{ip}]"), + }, + HostName::DomainName(domain_name) => domain_name.to_string(), + } + } +} + /// A validated kerberos realm name type, for use in CRDs. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] #[serde(try_from = "String", into = "String")] @@ -74,3 +166,48 @@ impl Deref for KerberosRealmName { &self.0 } } + +#[cfg(test)] +mod tests { + use super::*; + + use rstest::rstest; + + #[rstest] + #[case("foo")] + #[case("foo.bar")] + fn test_domain_name_and_host_name_parsing_success(#[case] domain_name: String) { + let parsed_domain_name: DomainName = + domain_name.parse().expect("domain name can not be parsed"); + // Every domain name is also a valid host name + let parsed_host_name: HostName = domain_name.parse().expect("host name can not be parsed"); + + // Also test the round-trip + assert_eq!(parsed_domain_name.to_string(), domain_name); + assert_eq!(parsed_host_name.to_string(), domain_name); + } + + #[rstest] + #[case("")] + #[case("foo.bar:1234")] + // FIXME: This should not be an valid domain name! + // #[case("1.2.3.4")] + #[case("fe80::1")] + fn test_domain_name_parsing_invalid_input(#[case] domain_name: &str) { + assert!(domain_name.parse::().is_err()); + } + + #[rstest] + #[case("foo", "foo")] + #[case("foo.bar", "foo.bar")] + #[case("1.2.3.4", "1.2.3.4")] + #[case("fe80::1", "[fe80::1]")] + fn test_host_name_parsing_success(#[case] host: &str, #[case] expected_url_host: &str) { + let parsed_host_name: HostName = host.parse().expect("host can not be parsed"); + + // Also test the round-trip + assert_eq!(parsed_host_name.to_string(), host); + + assert_eq!(parsed_host_name.as_url_host(), expected_url_host); + } +} diff --git a/crates/stackable-operator/src/commons/s3.rs b/crates/stackable-operator/src/commons/s3.rs deleted file mode 100644 index fd2bb8711..000000000 --- a/crates/stackable-operator/src/commons/s3.rs +++ /dev/null @@ -1,289 +0,0 @@ -//! Implementation of the bucket definition as described in -//! the -//! -//! Operator CRDs are expected to use the [S3BucketDef] as an entry point to this module -//! and obtain an [InlinedS3BucketSpec] by calling [`S3BucketDef::resolve`]. -//! -use kube::CustomResource; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use snafu::{ResultExt, Snafu}; - -use crate::{ - client::Client, - commons::{authentication::tls::Tls, secret_class::SecretClassVolume}, -}; - -type Result = std::result::Result; - -#[derive(Debug, Snafu)] -pub enum Error { - #[snafu(display("missing S3Connection {resource_name:?} in namespace {namespace:?}"))] - MissingS3Connection { - source: crate::client::Error, - resource_name: String, - namespace: String, - }, - - #[snafu(display("missing S3Bucket {resource_name:?} in namespace {namespace:?}"))] - MissingS3Bucket { - source: crate::client::Error, - resource_name: String, - namespace: String, - }, -} - -/// 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, Default, 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. - // FIXME: Try to remove the Option<>, as this field should be mandatory - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bucket_name: Option, - - /// The definition of an S3 connection, either inline or as a reference. - // FIXME: Try to remove the Option<>, as this field should be mandatory - #[serde(default, skip_serializing_if = "Option::is_none")] - pub connection: Option, -} - -impl S3BucketSpec { - /// Convenience function to retrieve the spec of a S3 bucket resource from the K8S API service. - pub async fn get( - resource_name: &str, - client: &Client, - namespace: &str, - ) -> Result { - client - .get::(resource_name, namespace) - .await - .map(|crd| crd.spec) - .context(MissingS3BucketSnafu { - resource_name, - namespace, - }) - } - - /// Map &self to an [InlinedS3BucketSpec] by obtaining connection spec from the K8S API service if necessary - pub async fn inlined(&self, client: &Client, namespace: &str) -> Result { - match self.connection.as_ref() { - Some(connection_def) => Ok(InlinedS3BucketSpec { - connection: Some(connection_def.resolve(client, namespace).await?), - bucket_name: self.bucket_name.clone(), - }), - None => Ok(InlinedS3BucketSpec { - bucket_name: self.bucket_name.clone(), - connection: None, - }), - } - } -} - -/// Convenience struct with the connection spec inlined. -pub struct InlinedS3BucketSpec { - pub bucket_name: Option, - pub connection: Option, -} - -impl InlinedS3BucketSpec { - /// Build the endpoint URL from [S3ConnectionSpec::host] and [S3ConnectionSpec::port] and the S3 implementation to use - pub fn endpoint(&self) -> Option { - self.connection - .as_ref() - .and_then(|connection| connection.endpoint()) - } -} - -/// An S3 bucket definition, it can either be a reference to an explicit S3Bucket object, -/// or it can be an inline definition of a bucket. Read the -/// [S3 resources concept documentation](DOCS_BASE_URL_PLACEHOLDER/concepts/s3) -/// to learn more. -#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum S3BucketDef { - /// An inline definition, containing the S3 bucket properties. - Inline(S3BucketSpec), - /// A reference to an S3 bucket object. This is simply the name of the `S3Bucket` - /// resource. - Reference(String), -} - -impl S3BucketDef { - /// Returns an [InlinedS3BucketSpec]. - pub async fn resolve(&self, client: &Client, namespace: &str) -> Result { - match self { - S3BucketDef::Inline(s3_bucket) => s3_bucket.inlined(client, namespace).await, - S3BucketDef::Reference(s3_bucket) => { - S3BucketSpec::get(s3_bucket.as_str(), client, namespace) - .await? - .inlined(client, namespace) - .await - } - } - } -} - -/// Operators are expected to define fields for this type in order to work with S3 connections. -#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum S3ConnectionDef { - /// Inline definition of an S3 connection. - Inline(S3ConnectionSpec), - /// A reference to an S3Connection resource. - Reference(String), -} - -impl S3ConnectionDef { - /// Returns an [S3ConnectionSpec]. - pub async fn resolve(&self, client: &Client, namespace: &str) -> Result { - match self { - S3ConnectionDef::Inline(s3_connection_spec) => Ok(s3_connection_spec.clone()), - S3ConnectionDef::Reference(s3_conn_reference) => { - S3ConnectionSpec::get(s3_conn_reference, client, namespace).await - } - } - } -} - -/// S3 connection definition as a resource. -/// Learn more on the [S3 concept documentation](DOCS_BASE_URL_PLACEHOLDER/concepts/s3). -#[derive( - CustomResource, Clone, Debug, Default, 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 { - /// Hostname of the S3 server without any protocol or port. For example: `west1.my-cloud.com`. - // FIXME: Try to remove the Option<>, as this field should be mandatory - #[serde(default, skip_serializing_if = "Option::is_none")] - pub host: Option, - - /// 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, - - // FIXME: Try to remove the Option<>, as this field should be mandatory - /// 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, skip_serializing_if = "Option::is_none")] - pub access_style: Option, - - /// 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, - - /// If you want to use TLS when talking to S3 you can enable TLS encrypted communication with this setting. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tls: Option, -} - -impl S3ConnectionSpec { - /// Convenience function to retrieve the spec of a S3 connection resource from the K8S API service. - pub async fn get( - resource_name: &str, - client: &Client, - namespace: &str, - ) -> Result { - client - .get::(resource_name, namespace) - .await - .map(|conn| conn.spec) - .context(MissingS3ConnectionSnafu { - resource_name, - namespace, - }) - } - - /// Build the endpoint URL from this connection - pub fn endpoint(&self) -> Option { - let protocol = match self.tls.as_ref() { - Some(_tls) => "https", - _ => "http", - }; - self.host.as_ref().map(|h| match self.port { - Some(p) => format!("{protocol}://{h}:{p}"), - None => format!("{protocol}://{h}"), - }) - } -} - -#[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, -} - -#[cfg(test)] -mod test { - use std::str; - - use crate::commons::s3::{S3AccessStyle, S3ConnectionDef}; - use crate::commons::s3::{S3BucketSpec, S3ConnectionSpec}; - use crate::yaml; - - #[test] - fn test_ser_inline() { - let bucket = S3BucketSpec { - bucket_name: Some("test-bucket-name".to_owned()), - connection: Some(S3ConnectionDef::Inline(S3ConnectionSpec { - host: Some("host".to_owned()), - port: Some(8080), - credentials: None, - access_style: Some(S3AccessStyle::VirtualHosted), - tls: None, - })), - }; - - let mut buf = Vec::new(); - yaml::serialize_to_explicit_document(&mut buf, &bucket).expect("serializable value"); - let actual_yaml = str::from_utf8(&buf).expect("UTF-8 encoded document"); - - let expected_yaml = "--- -bucketName: test-bucket-name -connection: - inline: - host: host - port: 8080 - accessStyle: VirtualHosted -"; - - assert_eq!(expected_yaml, actual_yaml) - } -} diff --git a/crates/stackable-operator/src/commons/s3/crd.rs b/crates/stackable-operator/src/commons/s3/crd.rs new file mode 100644 index 000000000..6905a396b --- /dev/null +++ b/crates/stackable-operator/src/commons/s3/crd.rs @@ -0,0 +1,88 @@ +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::commons::{ + networking::HostName, secret_class::SecretClassVolume, tls_verification::TlsClientDetails, +}; + +use super::S3ConnectionInlineOrReference; + +/// 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, + + /// 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, +} diff --git a/crates/stackable-operator/src/commons/s3/helpers.rs b/crates/stackable-operator/src/commons/s3/helpers.rs new file mode 100644 index 000000000..c6392dbe3 --- /dev/null +++ b/crates/stackable-operator/src/commons/s3/helpers.rs @@ -0,0 +1,309 @@ +use k8s_openapi::api::core::v1::{Volume, VolumeMount}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use snafu::ResultExt; +use url::Url; + +use crate::{ + builder::pod::{container::ContainerBuilder, volume::VolumeMountBuilder, PodBuilder}, + client::Client, + commons::authentication::SECRET_BASE_PATH, +}; + +use super::{ + AddS3CredentialVolumesSnafu, AddS3TlsClientDetailsVolumesSnafu, ParseS3EndpointSnafu, + RetrieveS3ConnectionSnafu, S3Bucket, S3BucketSpec, S3Connection, S3ConnectionSpec, S3Error, + SetS3EndpointSchemeSnafu, +}; + +#[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), +} + +/// 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), + } + } +} + +impl ResolvedS3Connection { + /// Build the endpoint URL from this connection + pub fn endpoint(&self) -> Result { + let endpoint = format!( + "http://{host}:{port}", + host = self.host.as_url_host(), + port = self.port() + ); + let mut url = Url::parse(&endpoint).context(ParseS3EndpointSnafu { endpoint })?; + + if self.tls.uses_tls() { + url.set_scheme("https").map_err(|_| { + SetS3EndpointSchemeSnafu { + scheme: "https".to_string(), + endpoint: url.clone(), + } + .build() + })?; + } + + Ok(url) + } + + /// Returns the port to be used, which is either user configured or defaulted based upon TLS usage + pub fn port(&self) -> u16 { + self.port + .unwrap_or(if self.tls.uses_tls() { 443 } else { 80 }) + } + + /// This functions adds + /// + /// * Credentials needed to connect to S3 + /// * Needed TLS volumes + /// + /// `unique_identifier` needs to be a unique identifier (e.g. in case of trino-operator the name of the catalog), + /// so that multiple mounts of the same SecretClass do not produce clashing volumes and volumeMounts. + pub fn add_volumes_and_mounts( + &self, + unique_identifier: &str, + pod_builder: &mut PodBuilder, + container_builders: Vec<&mut ContainerBuilder>, + ) -> Result<(), S3Error> { + let (volumes, mounts) = self.volumes_and_mounts(unique_identifier)?; + pod_builder.add_volumes(volumes); + for cb in container_builders { + cb.add_volume_mounts(mounts.clone()); + } + + Ok(()) + } + + /// 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, + unique_identifier: &str, + ) -> Result<(Vec, Vec), S3Error> { + let mut volumes = Vec::new(); + let mut mounts = Vec::new(); + + if let Some(credentials) = &self.credentials { + let secret_class = &credentials.secret_class; + let volume_name = format!("{unique_identifier}-{secret_class}-s3-credentials"); + + volumes.push( + credentials + .to_volume(&volume_name) + .context(AddS3CredentialVolumesSnafu)?, + ); + mounts.push( + VolumeMountBuilder::new( + volume_name, + format!("{SECRET_BASE_PATH}/{unique_identifier}-{secret_class}"), + ) + .build(), + ); + } + + // Add needed TLS volumes + let (tls_volumes, tls_mounts) = self + .tls + .volumes_and_mounts() + .context(AddS3TlsClientDetailsVolumesSnafu)?; + volumes.extend(tls_volumes); + mounts.extend(tls_mounts); + + Ok((volumes, mounts)) + } + + /// Returns the path of the files containing bind user and password. + /// This will be None if there are no credentials for this LDAP connection. + /// + /// `unique_identifier` needs to be a unique identifier (e.g. in case of trino-operator the name of the catalog), + /// so that multiple mounts of the same SecretClass do not produce clashing volumes and volumeMounts. + pub fn credentials_mount_paths(&self, unique_identifier: &str) -> Option<(String, String)> { + self.credentials.as_ref().map(|bind_credentials| { + let secret_class = &bind_credentials.secret_class; + ( + format!("{SECRET_BASE_PATH}/{unique_identifier}-{secret_class}/accessKey"), + format!("{SECRET_BASE_PATH}/{unique_identifier}-{secret_class}/secretKey"), + ) + }) + } +} + +#[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 test { + use std::collections::BTreeMap; + + use crate::commons::{ + secret_class::SecretClassVolume, + tls_verification::{CaCert, Tls, TlsClientDetails, TlsServerVerification, TlsVerification}, + }; + + use super::*; + + // We cant test the correct resolve, as we can't mock the k8s API. + #[test] + fn test_http() { + let s3 = ResolvedS3Connection { + host: "minio".parse().unwrap(), + port: None, + access_style: Default::default(), + credentials: None, + tls: TlsClientDetails { tls: None }, + }; + let (volumes, mounts) = s3.volumes_and_mounts("lakehouse").unwrap(); + + assert_eq!(s3.endpoint().unwrap(), Url::parse("http://minio").unwrap()); + assert_eq!(volumes, vec![]); + assert_eq!(mounts, vec![]); + } + + #[test] + fn test_https() { + let s3 = ResolvedS3Connection { + host: "s3-eu-central-2.ionoscloud.com".parse().unwrap(), + port: None, + access_style: Default::default(), + credentials: Some(SecretClassVolume { + secret_class: "ionos-s3-credentials".to_string(), + scope: None, + }), + tls: TlsClientDetails { + tls: Some(Tls { + verification: TlsVerification::Server(TlsServerVerification { + ca_cert: CaCert::WebPki {}, + }), + }), + }, + }; + let (mut volumes, mut mounts) = s3.volumes_and_mounts("lakehouse").unwrap(); + + assert_eq!( + s3.endpoint().unwrap(), + Url::parse("https://s3-eu-central-2.ionoscloud.com").unwrap() + ); + assert_eq!(volumes.len(), 1); + let volume = volumes.remove(0); + assert_eq!(mounts.len(), 1); + let mount = mounts.remove(0); + + assert_eq!( + &volume.name, + "lakehouse-ionos-s3-credentials-s3-credentials" + ); + assert_eq!( + &volume + .ephemeral + .unwrap() + .volume_claim_template + .unwrap() + .metadata + .unwrap() + .annotations + .unwrap(), + &BTreeMap::from([( + "secrets.stackable.tech/class".to_string(), + "ionos-s3-credentials".to_string() + )]), + ); + + assert_eq!(mount.name, volume.name); + assert_eq!( + mount.mount_path, + "/stackable/secrets/lakehouse-ionos-s3-credentials" + ); + assert_eq!( + s3.credentials_mount_paths("lakehouse"), + Some(( + "/stackable/secrets/lakehouse-ionos-s3-credentials/accessKey".to_string(), + "/stackable/secrets/lakehouse-ionos-s3-credentials/secretKey".to_string() + )) + ); + } + + #[test] + fn test_https_without_verification() { + let s3 = ResolvedS3Connection { + host: "minio".parse().unwrap(), + port: Some(1234), + access_style: Default::default(), + credentials: None, + tls: TlsClientDetails { + tls: Some(Tls { + verification: TlsVerification::None {}, + }), + }, + }; + let (volumes, mounts) = s3.volumes_and_mounts("lakehouse").unwrap(); + + assert_eq!( + s3.endpoint().unwrap(), + Url::parse("https://minio:1234").unwrap() + ); + assert_eq!(volumes, vec![]); + assert_eq!(mounts, vec![]); + } +} diff --git a/crates/stackable-operator/src/commons/s3/mod.rs b/crates/stackable-operator/src/commons/s3/mod.rs new file mode 100644 index 000000000..c7f36c6eb --- /dev/null +++ b/crates/stackable-operator/src/commons/s3/mod.rs @@ -0,0 +1,34 @@ +mod crd; +mod helpers; + +pub use crd::*; +pub use helpers::*; + +use snafu::Snafu; +use url::Url; + +use super::{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 }, +} diff --git a/crates/stackable-operator/src/commons/tls_verification.rs b/crates/stackable-operator/src/commons/tls_verification.rs new file mode 100644 index 000000000..a29695a95 --- /dev/null +++ b/crates/stackable-operator/src/commons/tls_verification.rs @@ -0,0 +1,157 @@ +use k8s_openapi::api::core::v1::{Volume, VolumeMount}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use snafu::{ResultExt, Snafu}; + +use crate::{ + builder::pod::{container::ContainerBuilder, volume::VolumeMountBuilder, PodBuilder}, + commons::{ + authentication::SECRET_BASE_PATH, + secret_class::{SecretClassVolume, SecretClassVolumeError}, + }, +}; + +#[derive(Debug, PartialEq, Snafu)] +pub enum TlsClientDetailsError { + #[snafu(display("failed to convert secret class volume into named Kubernetes volume"))] + SecretClassVolume { source: SecretClassVolumeError }, +} + +#[derive( + Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, +)] +#[serde(rename_all = "camelCase")] +pub struct TlsClientDetails { + /// Use a TLS connection. If not specified no TLS will be used. + pub tls: Option, +} + +impl TlsClientDetails { + /// This functions adds + /// + /// * The needed volumes to the PodBuilder + /// * The needed volume_mounts to all the ContainerBuilder in the list (e.g. init + main container) + /// + /// This function will handle + /// + /// * Tls secret class used to verify the cert of the LDAP server + pub fn add_volumes_and_mounts( + &self, + pod_builder: &mut PodBuilder, + container_builders: Vec<&mut ContainerBuilder>, + ) -> Result<(), TlsClientDetailsError> { + let (volumes, mounts) = self.volumes_and_mounts()?; + pod_builder.add_volumes(volumes); + + for cb in container_builders { + cb.add_volume_mounts(mounts.clone()); + } + + Ok(()) + } + + /// 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), TlsClientDetailsError> { + let mut volumes = Vec::new(); + let mut mounts = Vec::new(); + + if let Some(secret_class) = self.tls_ca_cert_secret_class() { + let volume_name = format!("{secret_class}-ca-cert"); + let secret_class_volume = SecretClassVolume::new(secret_class.clone(), None); + let volume = secret_class_volume + .to_volume(&volume_name) + .context(SecretClassVolumeSnafu)?; + + volumes.push(volume); + mounts.push( + VolumeMountBuilder::new(volume_name, format!("{SECRET_BASE_PATH}/{secret_class}")) + .build(), + ); + } + + Ok((volumes, mounts)) + } + + /// Whether TLS is configured + pub const fn uses_tls(&self) -> bool { + self.tls.is_some() + } + + /// Whether TLS verification is configured. Returns `false` if TLS itself isn't configured + pub fn uses_tls_verification(&self) -> bool { + self.tls + .as_ref() + .map(|tls| tls.verification != TlsVerification::None {}) + .unwrap_or_default() + } + + /// Returns the path of the ca.crt that should be used to verify the LDAP server certificate + /// if TLS verification with a CA cert from a SecretClass is configured. + pub fn tls_ca_cert_mount_path(&self) -> Option { + self.tls_ca_cert_secret_class() + .map(|secret_class| format!("{SECRET_BASE_PATH}/{secret_class}/ca.crt")) + } + + /// Extracts the SecretClass that provides the CA cert used to verify the server certificate. + pub(crate) fn tls_ca_cert_secret_class(&self) -> Option { + if let Some(Tls { + verification: + TlsVerification::Server(TlsServerVerification { + ca_cert: CaCert::SecretClass(secret_class), + }), + }) = &self.tls + { + Some(secret_class.to_owned()) + } else { + None + } + } +} + +#[derive( + Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, +)] +#[serde(rename_all = "camelCase")] +pub struct Tls { + /// The verification method used to verify the certificates of the server and/or the client. + pub verification: TlsVerification, +} + +#[derive( + Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, +)] +#[serde(rename_all = "camelCase")] +pub enum TlsVerification { + /// Use TLS but don't verify certificates. + None {}, + + /// Use TLS and a CA certificate to verify the server. + Server(TlsServerVerification), +} + +#[derive( + Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, +)] +#[serde(rename_all = "camelCase")] +pub struct TlsServerVerification { + /// CA cert to verify the server. + pub ca_cert: CaCert, +} + +#[derive( + Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, +)] +#[serde(rename_all = "camelCase")] +pub enum CaCert { + /// Use TLS and the CA certificates trusted by the common web browsers to verify the server. + /// This can be useful when you e.g. use public AWS S3 or other public available services. + WebPki {}, + + /// Name of the [SecretClass](DOCS_BASE_URL_PLACEHOLDER/secret-operator/secretclass) which will provide the CA certificate. + /// Note that a SecretClass does not need to have a key but can also work with just a CA certificate, + /// so if you got provided with a CA cert but don't have access to the key you can still use this method. + SecretClass(String), +} diff --git a/crates/stackable-operator/src/validation.rs b/crates/stackable-operator/src/validation.rs index 6cf4dbaaa..4472e4aaf 100644 --- a/crates/stackable-operator/src/validation.rs +++ b/crates/stackable-operator/src/validation.rs @@ -15,6 +15,8 @@ use const_format::concatcp; use regex::Regex; use snafu::Snafu; +// FIXME: According to https://www.rfc-editor.org/rfc/rfc1035#section-2.3.1 domain names must start with a letter +// (and not a number). const RFC_1123_LABEL_FMT: &str = "[a-z0-9]([-a-z0-9]*[a-z0-9])?"; const RFC_1123_SUBDOMAIN_FMT: &str = concatcp!(RFC_1123_LABEL_FMT, "(\\.", RFC_1123_LABEL_FMT, ")*"); @@ -23,7 +25,7 @@ const RFC_1123_LABEL_ERROR_MSG: &str = "a lowercase RFC 1123 label must consist // This is a subdomain's max length in DNS (RFC 1123) const RFC_1123_SUBDOMAIN_MAX_LENGTH: usize = 253; -// Minimal length reuquired by RFC 1123 is 63. Up to 255 allowed, unsupported by k8s. +// Minimal length required by RFC 1123 is 63. Up to 255 allowed, unsupported by k8s. const RFC_1123_LABEL_MAX_LENGTH: usize = 63; const RFC_1035_LABEL_FMT: &str = "[a-z]([-a-z0-9]*[a-z0-9])?";