diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e3b1a1a..768ac730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Kerberos authentication now works with KRaft controllers (`spec.controllers`), securing the + `CONTROLLER` listener used for broker/controller and controller/controller Raft RPC traffic ([#TBD]). + ### Changed - Internal operator refactoring: introduce a build() step in the reconciler that diff --git a/docs/modules/kafka/pages/usage-guide/kraft-controller.adoc b/docs/modules/kafka/pages/usage-guide/kraft-controller.adoc index 455188c9..cad71d56 100644 --- a/docs/modules/kafka/pages/usage-guide/kraft-controller.adoc +++ b/docs/modules/kafka/pages/usage-guide/kraft-controller.adoc @@ -78,6 +78,17 @@ controllers: The configuration of overrides, JVM arguments etc. is similar to the Broker and documented on the xref:concepts:overrides.adoc[concepts page]. +== Kerberos + +Kerberos authentication is supported for KRaft clusters: enabling a Kerberos `AuthenticationClass` +secures the `CLIENT`, `BOOTSTRAP` and `CONTROLLER` listeners with GSSAPI, including +controller-to-controller and broker-to-controller Raft RPC traffic on the `CONTROLLER` listener. +The `INTERNAL` inter-broker listener continues to use mutual TLS and is unaffected by Kerberos. + +NOTE: SASL/SCRAM is not supported for the controller listener by Apache Kafka itself +(https://issues.apache.org/jira/browse/KAFKA-15513[KAFKA-15513]); this does not affect Kerberos +(GSSAPI), which authenticates against the external KDC rather than Kafka-internal credential storage. + == Internal operator details KRaft mode requires major configuration changes compared to ZooKeeper: @@ -91,7 +102,6 @@ KRaft mode requires major configuration changes compared to ZooKeeper: * Automatic migration from Apache ZooKeeper to KRaft is not supported. * Scaling controller replicas might lead to unstable clusters. -* Kerberos is currently not supported for KRaft in all versions. == Troubleshooting diff --git a/docs/modules/kafka/partials/supported-versions.adoc b/docs/modules/kafka/partials/supported-versions.adoc index 1a57dce0..abd30e50 100644 --- a/docs/modules/kafka/partials/supported-versions.adoc +++ b/docs/modules/kafka/partials/supported-versions.adoc @@ -12,5 +12,5 @@ Support for clusters running in Kraft mode (which includes Apache Kafka 4.x.x) i Also there are some known issues such as: * Controller scaling is not reliable. -* Kerberos authentication is not tested yet. * Service exposition is not definitive. +* Kerberos authentication for KRaft is implemented, unit-tested, and has been verified end-to-end against a live cluster (Kafka 4.2.1, 3 controller replicas); it has not yet been exercised across all supported versions in CI. diff --git a/rust/operator-binary/src/controller/build/command.rs b/rust/operator-binary/src/controller/build/command.rs index 4805a623..9b03c64b 100644 --- a/rust/operator-binary/src/controller/build/command.rs +++ b/rust/operator-binary/src/controller/build/command.rs @@ -157,6 +157,7 @@ wait_for_termination() "#; pub fn controller_kafka_container_command( + kafka_security: &ValidatedKafkaSecurity, controller_descriptors: Vec, product_version: &str, ) -> String { @@ -165,14 +166,14 @@ pub fn controller_kafka_container_command( {remove_vector_shutdown_file_command} prepare_signal_handlers containerdebug --output={STACKABLE_LOG_DIR}/containerdebug-state.json --loop & - + {set_realm_env} POD_INDEX=$(echo \"$POD_NAME\" | grep -oE '[0-9]+$') export REPLICA_ID=$((POD_INDEX+NODE_ID_OFFSET)) cp {config_dir}/{properties_file} /tmp/{properties_file} config-utils template /tmp/{properties_file} - + {jaas_setup} bin/kafka-storage.sh format --cluster-id \"$KAFKA_CLUSTER_ID\" --config /tmp/{properties_file} --ignore-formatted {initial_controller_command} bin/kafka-server-start.sh /tmp/{properties_file} & @@ -180,8 +181,25 @@ pub fn controller_kafka_container_command( {create_vector_shutdown_file_command} ", remove_vector_shutdown_file_command = remove_vector_shutdown_file_command(STACKABLE_LOG_DIR), + // When Kerberos is disabled this resolves to an empty string, so the surrounding + // template lines collapse to the same single blank line that was present before + // Kerberos support was added (byte-identical output for non-Kerberos setups). + set_realm_env = match kafka_security.has_kerberos_enabled() { + true => format!("export KERBEROS_REALM=$(grep -oP 'default_realm = \\K.*' {STACKABLE_KERBEROS_KRB5_PATH})\n"), + false => "".to_string(), + }, config_dir = STACKABLE_CONFIG_DIR, properties_file = ConfigFileName::ControllerProperties, + // Same as `set_realm_env`: empty when Kerberos is disabled, preserving the + // pre-Kerberos-support blank-line layout. + jaas_setup = match kafka_security.has_kerberos_enabled() { + true => format!( + "\ncp {config_dir}/{jaas_file} /tmp/{jaas_file}\nconfig-utils template /tmp/{jaas_file}\n", + config_dir = STACKABLE_CONFIG_DIR, + jaas_file = ConfigFileName::Jaas, + ), + false => "".to_string(), + }, initial_controller_command = initial_controllers_command(&controller_descriptors, product_version), create_vector_shutdown_file_command = create_vector_shutdown_file_command(STACKABLE_LOG_DIR) } @@ -207,3 +225,115 @@ fn initial_controllers_command( ), } } + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use stackable_operator::{ + builder::meta::ObjectMetaBuilder, + crd::authentication::{core, kerberos}, + v2::types::kubernetes::SecretClassName, + }; + + use super::*; + use crate::crd::authentication::ResolvedAuthenticationClasses; + + fn kerberos_auth_class() -> core::v1alpha1::AuthenticationClass { + core::v1alpha1::AuthenticationClass { + metadata: ObjectMetaBuilder::new().name("kerberos-auth").build(), + spec: core::v1alpha1::AuthenticationClassSpec { + provider: core::v1alpha1::AuthenticationClassProvider::Kerberos( + kerberos::v1alpha1::AuthenticationProvider { + kerberos_secret_class: "kerberos-secret-class".to_string(), + }, + ), + }, + } + } + + /// Kerberos, which also requires server and internal TLS. + fn kerberos_security() -> ValidatedKafkaSecurity { + ValidatedKafkaSecurity::new( + ResolvedAuthenticationClasses::new(vec![kerberos_auth_class()]), + SecretClassName::from_str("tls").expect("tls secret class name is valid"), + Some("tls".parse().unwrap()), + None, + ) + } + + /// Plaintext: no TLS, no authentication, no OPA. + fn plaintext_security() -> ValidatedKafkaSecurity { + ValidatedKafkaSecurity::new( + ResolvedAuthenticationClasses::new(vec![]), + SecretClassName::from_str("tls").expect("tls secret class name is valid"), + None, + None, + ) + } + + #[test] + fn controller_command_exports_kerberos_realm_and_templates_jaas_when_enabled() { + let command = controller_kafka_container_command(&kerberos_security(), vec![], "4.1.1"); + assert!(command.contains("export KERBEROS_REALM=")); + assert!(command.contains(&format!( + "cp {}/jaas.properties /tmp/jaas.properties", + STACKABLE_CONFIG_DIR + ))); + assert!(command.contains("config-utils template /tmp/jaas.properties")); + } + + #[test] + fn controller_command_skips_kerberos_setup_when_disabled() { + let command = controller_kafka_container_command(&plaintext_security(), vec![], "4.1.1"); + assert!(!command.contains("KERBEROS_REALM")); + assert!(!command.contains("jaas.properties")); + } + + /// Mirrors `controller_kafka_container_command` as it existed at commit `d9942ad` + /// (immediately before Kerberos support was added), before it took a `kafka_security` + /// parameter. Used to pin down that Kerberos-disabled output is byte-identical to the + /// pre-Kerberos-support output, per the plan's Global Constraint. + fn pre_kerberos_controller_kafka_container_command( + controller_descriptors: Vec, + product_version: &str, + ) -> String { + formatdoc! {" + {BASH_TRAP_FUNCTIONS} + {remove_vector_shutdown_file_command} + prepare_signal_handlers + containerdebug --output={STACKABLE_LOG_DIR}/containerdebug-state.json --loop & + + POD_INDEX=$(echo \"$POD_NAME\" | grep -oE '[0-9]+$') + export REPLICA_ID=$((POD_INDEX+NODE_ID_OFFSET)) + + cp {config_dir}/{properties_file} /tmp/{properties_file} + + config-utils template /tmp/{properties_file} + + bin/kafka-storage.sh format --cluster-id \"$KAFKA_CLUSTER_ID\" --config /tmp/{properties_file} --ignore-formatted {initial_controller_command} + bin/kafka-server-start.sh /tmp/{properties_file} & + + wait_for_termination $! + {create_vector_shutdown_file_command} + ", + remove_vector_shutdown_file_command = remove_vector_shutdown_file_command(STACKABLE_LOG_DIR), + config_dir = STACKABLE_CONFIG_DIR, + properties_file = ConfigFileName::ControllerProperties, + initial_controller_command = initial_controllers_command(&controller_descriptors, product_version), + create_vector_shutdown_file_command = create_vector_shutdown_file_command(STACKABLE_LOG_DIR) + } + } + + #[test] + fn controller_command_is_byte_identical_to_pre_kerberos_output_when_disabled() { + let actual = controller_kafka_container_command(&plaintext_security(), vec![], "4.1.1"); + let expected = pre_kerberos_controller_kafka_container_command(vec![], "4.1.1"); + + assert_eq!( + actual, expected, + "controller_kafka_container_command must produce byte-identical output to the \ + pre-Kerberos-support implementation when Kerberos is disabled" + ); + } +} diff --git a/rust/operator-binary/src/controller/build/kerberos.rs b/rust/operator-binary/src/controller/build/kerberos.rs index 676c45b0..46c5842b 100644 --- a/rust/operator-binary/src/controller/build/kerberos.rs +++ b/rust/operator-binary/src/controller/build/kerberos.rs @@ -41,22 +41,37 @@ pub enum Error { pub fn add_kerberos_pod_config( kafka_security: &ValidatedKafkaSecurity, role: &KafkaRole, - cb_kcat_prober: &mut ContainerBuilder, + cb_kcat_prober: Option<&mut ContainerBuilder>, cb_kafka: &mut ContainerBuilder, pb: &mut PodBuilder, ) -> Result<(), Error> { if let Some(kerberos_secret_class) = kafka_security.kerberos_secret_class() { - // Mount keytab - let kerberos_secret_operator_volume = SecretOperatorVolumeSourceBuilder::new( + let mut volume_builder = SecretOperatorVolumeSourceBuilder::new( kerberos_secret_class, // We need both public (krb5.conf) and private (keytab) parts. SecretClassVolumeProvisionParts::PublicPrivate, - ) - .with_listener_volume_scope(LISTENER_BROKER_VOLUME_NAME) - .with_listener_volume_scope(LISTENER_BOOTSTRAP_VOLUME_NAME) - .with_kerberos_service_name(role.kerberos_service_name()) - .build() - .context(KerberosSecretVolumeSnafu)?; + ); + match role { + // Brokers are exposed through listener-operator `Listener` volumes (the client + // and bootstrap listeners); the keytab principal must cover both. + KafkaRole::Broker => { + volume_builder + .with_listener_volume_scope(LISTENER_BROKER_VOLUME_NAME) + .with_listener_volume_scope(LISTENER_BOOTSTRAP_VOLUME_NAME); + } + // KRaft controllers have no listener-operator `Listener` volume (see + // `controller/build/mod.rs`, "Only broker role groups get a bootstrap Listener"): + // they're only reachable through their own StatefulSet pod DNS name, so the keytab + // must be pod-scoped, matching how the controller's internal TLS cert is provisioned + // in `add_controller_volume_and_volume_mounts`. + KafkaRole::Controller => { + volume_builder.with_pod_scope(); + } + }; + let kerberos_secret_operator_volume = volume_builder + .with_kerberos_service_name(role.kerberos_service_name()) + .build() + .context(KerberosSecretVolumeSnafu)?; pb.add_volume( VolumeBuilder::new("kerberos") .ephemeral(kerberos_secret_operator_volume) @@ -64,7 +79,11 @@ pub fn add_kerberos_pod_config( ) .context(AddVolumeSnafu)?; - for cb in [cb_kafka, cb_kcat_prober] { + let mut containers: Vec<&mut ContainerBuilder> = vec![cb_kafka]; + if let Some(cb_kcat_prober) = cb_kcat_prober { + containers.push(cb_kcat_prober); + } + for cb in containers { cb.add_volume_mount("kerberos", STACKABLE_KERBEROS_DIR) .context(AddVolumeMountSnafu)?; cb.add_env_var("KRB5_CONFIG", STACKABLE_KERBEROS_KRB5_PATH); @@ -77,3 +96,85 @@ pub fn add_kerberos_pod_config( Ok(()) } + +#[cfg(test)] +mod tests { + use stackable_operator::{ + builder::{meta::ObjectMetaBuilder, pod::container::ContainerBuilder}, + crd::authentication::{core, kerberos}, + }; + + use super::*; + use crate::crd::authentication::ResolvedAuthenticationClasses; + + fn kerberos_security() -> ValidatedKafkaSecurity { + ValidatedKafkaSecurity::new( + ResolvedAuthenticationClasses::new(vec![core::v1alpha1::AuthenticationClass { + metadata: ObjectMetaBuilder::new().name("kerberos-auth").build(), + spec: core::v1alpha1::AuthenticationClassSpec { + provider: core::v1alpha1::AuthenticationClassProvider::Kerberos( + kerberos::v1alpha1::AuthenticationProvider { + kerberos_secret_class: "kerberos-secret-class".to_string(), + }, + ), + }, + }]), + "tls".parse().unwrap(), + Some("tls".parse().unwrap()), + None, + ) + } + + #[test] + fn controller_role_mounts_pod_scoped_keytab_without_kcat_container() { + let mut pb = PodBuilder::new(); + let mut cb_kafka = ContainerBuilder::new("kafka").expect("valid container name"); + + add_kerberos_pod_config( + &kerberos_security(), + &KafkaRole::Controller, + None, + &mut cb_kafka, + &mut pb, + ) + .expect("kerberos pod config for controller role"); + + let pod = pb.build_template(); + let kerberos_volume = pod + .spec + .as_ref() + .and_then(|spec| spec.volumes.as_ref()) + .and_then(|volumes| volumes.iter().find(|v| v.name == "kerberos")) + .expect("kerberos volume must be present"); + let ephemeral = kerberos_volume + .ephemeral + .as_ref() + .expect("kerberos volume must be an ephemeral (secret-operator) volume"); + let annotations = ephemeral + .volume_claim_template + .as_ref() + .and_then(|t| t.metadata.as_ref()) + .and_then(|m| m.annotations.as_ref()) + .expect("volume claim template must carry secrets.stackable.tech annotations"); + // Pod-scoping (`with_pod_scope()`) is expressed as a `secrets.stackable.tech/scope: pod` + // annotation (same as the controller's internal TLS cert, see + // `add_controller_volume_and_volume_mounts`) -- it must not mention a listener volume. + assert_eq!( + annotations + .get("secrets.stackable.tech/scope") + .map(String::as_str), + Some("pod"), + "controller keytab must be pod-scoped only, not listener-volume-scoped: {annotations:?}" + ); + + let kafka_container = cb_kafka.build(); + let env_names: Vec<_> = kafka_container + .env + .unwrap_or_default() + .into_iter() + .map(|e| e.name) + .collect(); + assert!(env_names.contains(&"KRB5_CONFIG".to_string())); + assert!(env_names.contains(&"KAFKA_OPTS".to_string())); + } +} diff --git a/rust/operator-binary/src/controller/build/properties/listener.rs b/rust/operator-binary/src/controller/build/properties/listener.rs index 3ba733bf..acc63255 100644 --- a/rust/operator-binary/src/controller/build/properties/listener.rs +++ b/rust/operator-binary/src/controller/build/properties/listener.rs @@ -108,8 +108,14 @@ pub fn get_kafka_listener_config( port: kafka_security.internal_port().to_string(), }); listener_security_protocol_map.insert(KafkaListenerName::Internal, KafkaListenerProtocol::Ssl); - listener_security_protocol_map - .insert(KafkaListenerName::Controller, KafkaListenerProtocol::Ssl); + listener_security_protocol_map.insert( + KafkaListenerName::Controller, + if kafka_security.has_kerberos_enabled() { + KafkaListenerProtocol::SaslSsl + } else { + KafkaListenerProtocol::Ssl + }, + ); // BOOTSTRAP if kafka_security.has_kerberos_enabled() { @@ -492,7 +498,7 @@ mod tests { bootstrap_name = KafkaListenerName::Bootstrap, bootstrap_protocol = KafkaListenerProtocol::SaslSsl, controller_name = KafkaListenerName::Controller, - controller_protocol = KafkaListenerProtocol::Ssl, + controller_protocol = KafkaListenerProtocol::SaslSsl, ) ); } diff --git a/rust/operator-binary/src/controller/build/resource/config_map.rs b/rust/operator-binary/src/controller/build/resource/config_map.rs index 284a281b..85e468b1 100644 --- a/rust/operator-binary/src/controller/build/resource/config_map.rs +++ b/rust/operator-binary/src/controller/build/resource/config_map.rs @@ -23,7 +23,7 @@ use crate::{ crd::{ STACKABLE_LISTENER_BOOTSTRAP_DIR, STACKABLE_LISTENER_BROKER_DIR, listener::{KafkaListenerConfig, node_address_cmd}, - role::AnyConfig, + role::{AnyConfig, KafkaRole}, }, }; @@ -169,7 +169,7 @@ pub fn build_rolegroup_config_map( // and this tool currently doesn't support the JAAS login configuration format. .add_data( ConfigFileName::Jaas.to_string(), - jaas_config_file(kafka_security.has_kerberos_enabled()), + jaas_config_file(kafka_security.has_kerberos_enabled(), &role), ); tracing::debug!(?kafka_config, "Applied kafka config"); @@ -199,29 +199,64 @@ pub fn build_rolegroup_config_map( // Generate JAAS configuration file for Kerberos authentication // or an empty string if Kerberos is not enabled. // See https://docs.oracle.com/javase/8/docs/technotes/guides/security/jgss/tutorials/LoginConfigFile.html -fn jaas_config_file(is_kerberos_enabled: bool) -> String { - match is_kerberos_enabled { - false => String::new(), - true => formatdoc! {" - bootstrap.KafkaServer {{ - com.sun.security.auth.module.Krb5LoginModule required - useKeyTab=true - storeKey=true - isInitiator=false - keyTab=\"/stackable/kerberos/keytab\" - principal=\"kafka/{bootstrap_address}@${{env:KERBEROS_REALM}}\"; - }}; +fn jaas_config_file(is_kerberos_enabled: bool, role: &KafkaRole) -> String { + if !is_kerberos_enabled { + return String::new(); + } - client.KafkaServer {{ + // Broker pods reach the CONTROLLER listener as SASL clients; the only principals in their + // keytab (see `add_kerberos_pod_config`) are for the broker/bootstrap listener addresses, so + // the CONTROLLER section must reuse the same address as `client.KafkaServer`. + // Controller pods have no listener-operator Listener volume; their keytab is pod-scoped, so + // the CONTROLLER section must use their own pod FQDN — the same template already used for + // `KAFKA_LISTENERS` in `controller_properties.rs`. + let controller_principal_address = match role { + KafkaRole::Broker => node_address_cmd(STACKABLE_LISTENER_BROKER_DIR), + KafkaRole::Controller => { + "${env:POD_NAME}.${env:ROLEGROUP_HEADLESS_SERVICE_NAME}.${env:NAMESPACE}.svc.${env:CLUSTER_DOMAIN}" + .to_string() + } + }; + + // Unlike the bootstrap/client sections below, this context is used for BOTH sides of every + // CONTROLLER-listener connection: brokers connect out to controllers, and controllers also + // connect to each other for Raft. So this is the only listener in this operator where the + // process must be able to act as a GSSAPI initiator as well as an acceptor, hence + // `isInitiator` is intentionally left at its default (`true`) here. + let controller_section = formatdoc! {" + controller.KafkaServer {{ com.sun.security.auth.module.Krb5LoginModule required useKeyTab=true storeKey=true - isInitiator=false keyTab=\"/stackable/kerberos/keytab\" - principal=\"kafka/{broker_address}@${{env:KERBEROS_REALM}}\"; + principal=\"kafka/{controller_principal_address}@${{env:KERBEROS_REALM}}\"; }}; - ", + }; + + match role { + KafkaRole::Controller => controller_section, + KafkaRole::Broker => formatdoc! {" + bootstrap.KafkaServer {{ + com.sun.security.auth.module.Krb5LoginModule required + useKeyTab=true + storeKey=true + isInitiator=false + keyTab=\"/stackable/kerberos/keytab\" + principal=\"kafka/{bootstrap_address}@${{env:KERBEROS_REALM}}\"; + }}; + + client.KafkaServer {{ + com.sun.security.auth.module.Krb5LoginModule required + useKeyTab=true + storeKey=true + isInitiator=false + keyTab=\"/stackable/kerberos/keytab\" + principal=\"kafka/{broker_address}@${{env:KERBEROS_REALM}}\"; + }}; + + {controller_section} + ", bootstrap_address = node_address_cmd(STACKABLE_LISTENER_BOOTSTRAP_DIR), broker_address = node_address_cmd(STACKABLE_LISTENER_BROKER_DIR), }, @@ -231,15 +266,17 @@ fn jaas_config_file(is_kerberos_enabled: bool) -> String { #[cfg(test)] mod tests { use super::jaas_config_file; + use crate::crd::role::KafkaRole; #[test] fn jaas_config_file_empty_without_kerberos() { - assert_eq!(jaas_config_file(false), ""); + assert_eq!(jaas_config_file(false, &KafkaRole::Broker), ""); + assert_eq!(jaas_config_file(false, &KafkaRole::Controller), ""); } #[test] fn jaas_config_file_renders_bootstrap_and_client_sections_with_kerberos() { - let jaas = jaas_config_file(true); + let jaas = jaas_config_file(true, &KafkaRole::Broker); assert!(jaas.contains("bootstrap.KafkaServer")); assert!(jaas.contains("client.KafkaServer")); assert!(jaas.contains("Krb5LoginModule")); @@ -248,4 +285,37 @@ mod tests { assert!(jaas.contains("/stackable/listener-bootstrap")); assert!(jaas.contains("/stackable/listener-broker")); } + + #[test] + fn jaas_config_file_broker_has_controller_section_using_broker_address() { + let jaas = jaas_config_file(true, &KafkaRole::Broker); + assert!(jaas.contains("controller.KafkaServer {")); + assert!(jaas.contains( + "kafka/${file:UTF-8:/stackable/listener-broker/default-address/address}@${env:KERBEROS_REALM}" + )); + } + + #[test] + fn jaas_config_file_controller_has_controller_section_using_pod_fqdn() { + let jaas = jaas_config_file(true, &KafkaRole::Controller); + assert!(jaas.contains("controller.KafkaServer {")); + assert!(jaas.contains( + "kafka/${env:POD_NAME}.${env:ROLEGROUP_HEADLESS_SERVICE_NAME}.${env:NAMESPACE}.svc.${env:CLUSTER_DOMAIN}@${env:KERBEROS_REALM}" + )); + // Controllers have no listener-operator Listener volume, so the broker-only sections + // must not appear in their JAAS file. + assert!(!jaas.contains("bootstrap.KafkaServer")); + assert!(!jaas.contains("client.KafkaServer")); + + // The controller.KafkaServer section must NOT set isInitiator=false: it is used both + // when brokers connect to controllers and when controllers connect to each other for + // Raft, so the process needs to be able to act as a GSSAPI initiator on this listener. + // Scope the check to the controller section itself (rather than a global absence check) + // so that a future broker-side isInitiator=false stays fine. + let controller_section_start = jaas + .find("controller.KafkaServer {") + .expect("controller.KafkaServer section must be present"); + let controller_section = &jaas[controller_section_start..]; + assert!(!controller_section.contains("isInitiator=false")); + } } diff --git a/rust/operator-binary/src/controller/build/resource/statefulset.rs b/rust/operator-binary/src/controller/build/resource/statefulset.rs index c9147c55..907f0ca2 100644 --- a/rust/operator-binary/src/controller/build/resource/statefulset.rs +++ b/rust/operator-binary/src/controller/build/resource/statefulset.rs @@ -243,7 +243,7 @@ pub fn build_broker_rolegroup_statefulset( add_kerberos_pod_config( kafka_security, kafka_role, - &mut cb_kcat_prober, + Some(&mut cb_kcat_prober), &mut cb_kafka, &mut pod_builder, ) @@ -498,6 +498,7 @@ pub fn build_controller_rolegroup_statefulset( "-c".to_string(), ]) .args(vec![controller_kafka_container_command( + kafka_security, validated_cluster .pod_descriptors(Some(kafka_role)) .context(BuildPodDescriptorsSnafu)?, @@ -572,6 +573,17 @@ pub fn build_controller_rolegroup_statefulset( ) .context(AddVolumesAndVolumeMountsSnafu)?; + if kafka_security.has_kerberos_enabled() { + add_kerberos_pod_config( + kafka_security, + kafka_role, + None, + &mut cb_kafka, + &mut pod_builder, + ) + .context(AddKerberosConfigSnafu)?; + } + let kafka_container = cb_kafka.build(); pod_builder @@ -801,3 +813,192 @@ fn add_vector_container( )); } } + +#[cfg(test)] +mod tests { + use stackable_operator::{ + builder::meta::ObjectMetaBuilder, + crd::authentication::{core, kerberos}, + }; + + use super::*; + use crate::{ + controller::test_support::{minimal_kafka, validated_cluster}, + crd::authentication::ResolvedAuthenticationClasses, + }; + + /// A Kerberos-enabled [`ValidatedKafkaSecurity`], mirroring the fixture used by + /// `add_kerberos_pod_config`'s own tests (`controller/build/kerberos.rs`). + fn kerberos_security() -> ValidatedKafkaSecurity { + ValidatedKafkaSecurity::new( + ResolvedAuthenticationClasses::new(vec![core::v1alpha1::AuthenticationClass { + metadata: ObjectMetaBuilder::new().name("kerberos-auth").build(), + spec: core::v1alpha1::AuthenticationClassSpec { + provider: core::v1alpha1::AuthenticationClassProvider::Kerberos( + kerberos::v1alpha1::AuthenticationProvider { + kerberos_secret_class: "kerberos-secret-class".to_string(), + }, + ), + }, + }]), + "tls".parse().unwrap(), + Some("tls".parse().unwrap()), + None, + ) + } + + /// A KRaft cluster with one `controller` and one `broker` role group, resolved through the + /// real validate step (mirroring the fixtures in `controller/build/mod.rs`'s tests). The + /// `kafka_security` is swapped for a Kerberos-enabled one afterwards, since `validate()` + /// only resolves auth classes that are actually referenced from the cluster spec, and both + /// `ValidatedCluster::cluster_config` and `ValidatedKafkaSecurity` fields are public. + fn kraft_cluster_with_kerberos() -> ValidatedCluster { + let kafka = minimal_kafka( + r#" + apiVersion: kafka.stackable.tech/v1alpha1 + kind: KafkaCluster + metadata: + name: simple-kafka + namespace: default + uid: 12345678-1234-1234-1234-123456789012 + spec: + image: + productVersion: 3.9.2 + clusterConfig: + metadataManager: kraft + controllers: + roleGroups: + default: + replicas: 3 + brokers: + roleGroups: + default: + replicas: 3 + "#, + ); + let mut cluster = validated_cluster(&kafka); + cluster.cluster_config.kafka_security = kerberos_security(); + cluster + } + + #[test] + fn controller_statefulset_mounts_kerberos_when_enabled() { + let cluster = kraft_cluster_with_kerberos(); + let role_group_name: RoleGroupName = "default".parse().unwrap(); + let validated_rg = cluster + .role_group_configs + .get(&KafkaRole::Controller) + .expect("controller role group configs") + .get(&role_group_name) + .expect("default controller role group"); + + let sts = build_controller_rolegroup_statefulset( + &KafkaRole::Controller, + &role_group_name, + &cluster, + validated_rg, + ) + .expect("controller statefulset build"); + + let kafka_container = sts + .spec + .expect("statefulset spec") + .template + .spec + .expect("pod spec") + .containers + .into_iter() + .find(|c| c.name == "kafka") + .expect("kafka container"); + + let env_names: Vec<_> = kafka_container + .env + .unwrap_or_default() + .into_iter() + .map(|e| e.name) + .collect(); + assert!(env_names.contains(&"KRB5_CONFIG".to_string())); + assert!(env_names.contains(&"KAFKA_OPTS".to_string())); + + let mount_names: Vec<_> = kafka_container + .volume_mounts + .unwrap_or_default() + .into_iter() + .map(|m| m.name) + .collect(); + assert!(mount_names.contains(&"kerberos".to_string())); + } + + /// Non-Kerberos controller StatefulSets must be unaffected: no `kerberos` volume mount and + /// no Kerberos env vars on the `kafka` container. + #[test] + fn controller_statefulset_has_no_kerberos_when_disabled() { + let kafka = minimal_kafka( + r#" + apiVersion: kafka.stackable.tech/v1alpha1 + kind: KafkaCluster + metadata: + name: simple-kafka + namespace: default + uid: 12345678-1234-1234-1234-123456789012 + spec: + image: + productVersion: 3.9.2 + clusterConfig: + metadataManager: kraft + controllers: + roleGroups: + default: + replicas: 3 + brokers: + roleGroups: + default: + replicas: 3 + "#, + ); + let cluster = validated_cluster(&kafka); + let role_group_name: RoleGroupName = "default".parse().unwrap(); + let validated_rg = cluster + .role_group_configs + .get(&KafkaRole::Controller) + .expect("controller role group configs") + .get(&role_group_name) + .expect("default controller role group"); + + let sts = build_controller_rolegroup_statefulset( + &KafkaRole::Controller, + &role_group_name, + &cluster, + validated_rg, + ) + .expect("controller statefulset build"); + + let kafka_container = sts + .spec + .expect("statefulset spec") + .template + .spec + .expect("pod spec") + .containers + .into_iter() + .find(|c| c.name == "kafka") + .expect("kafka container"); + + let env_names: Vec<_> = kafka_container + .env + .unwrap_or_default() + .into_iter() + .map(|e| e.name) + .collect(); + assert!(!env_names.contains(&"KRB5_CONFIG".to_string())); + assert!(!env_names.contains(&"KAFKA_OPTS".to_string())); + + let mount_names: Vec<_> = kafka_container + .volume_mounts + .unwrap_or_default() + .into_iter() + .map(|m| m.name) + .collect(); + assert!(!mount_names.contains(&"kerberos".to_string())); + } +} diff --git a/rust/operator-binary/src/controller/build/security.rs b/rust/operator-binary/src/controller/build/security.rs index e36191c1..77104d0c 100644 --- a/rust/operator-binary/src/controller/build/security.rs +++ b/rust/operator-binary/src/controller/build/security.rs @@ -49,6 +49,7 @@ const PROPERTY_SECURITY_PROTOCOL: &str = "security.protocol"; const PROPERTY_SASL_ENABLED_MECHANISMS: &str = "sasl.enabled.mechanisms"; const PROPERTY_SASL_KERBEROS_SERVICE_NAME: &str = "sasl.kerberos.service.name"; const PROPERTY_SASL_INTER_BROKER_MECHANISM: &str = "sasl.mechanism.inter.broker.protocol"; +const PROPERTY_SASL_CONTROLLER_MECHANISM: &str = "sasl.mechanism.controller.protocol"; const STACKABLE_TLS_KAFKA_INTERNAL_DIR: &str = "/stackable/tls-kafka-internal"; const STACKABLE_TLS_KAFKA_INTERNAL_VOLUME_NAME: &str = "tls-kafka-internal"; const STACKABLE_TLS_KAFKA_SERVER_DIR: &str = "/stackable/tls-kafka-server"; @@ -448,6 +449,10 @@ pub fn broker_config_settings(security: &ValidatedKafkaSecurity) -> BTreeMap BTreeMap PROPERTY_SASL_INTER_BROKER_MECHANISM.to_string(), SASL_MECHANISM_GSSAPI.to_string(), ); + config.insert( + PROPERTY_SASL_CONTROLLER_MECHANISM.to_string(), + SASL_MECHANISM_GSSAPI.to_string(), + ); tracing::debug!("Kerberos configs added: [{:#?}]", config); } @@ -933,6 +942,10 @@ mod tests { config.get("sasl.mechanism.inter.broker.protocol"), Some(&"GSSAPI".to_string()) ); + assert_eq!( + config.get("sasl.mechanism.controller.protocol"), + Some(&"GSSAPI".to_string()) + ); assert!(config.contains_key("listener.name.bootstrap.ssl.keystore.location")); } @@ -988,5 +1001,9 @@ mod tests { config.get("sasl.kerberos.service.name"), Some(&"kafka".to_string()) ); + assert_eq!( + config.get("sasl.mechanism.controller.protocol"), + Some(&"GSSAPI".to_string()) + ); } } diff --git a/rust/operator-binary/src/crd/listener.rs b/rust/operator-binary/src/crd/listener.rs index 7aabadad..8e014d14 100644 --- a/rust/operator-binary/src/crd/listener.rs +++ b/rust/operator-binary/src/crd/listener.rs @@ -58,13 +58,10 @@ pub enum KafkaListenerName { /// This listener is defined when Kraft mode is enabled. /// It is responsible for broker/controller as well as controller/controller communications /// and therefore it is present on *both* brokers and controller properties files. - /// The only protocol used is SSL. + /// The protocol used is SSL, or SASL_SSL when Kerberos is enabled. /// The advertised host names are FQDN pod names of the controllers. /// - /// Notes: - /// - /// - there is no listener for client/controller communication - /// - this listener does not support SSL_SASL. + /// Note: there is no listener for client/controller communication. #[strum(serialize = "CONTROLLER")] Controller, } diff --git a/tests/templates/kuttl/kraft-kerberos/00-assert.yaml.j2 b/tests/templates/kuttl/kraft-kerberos/00-assert.yaml.j2 new file mode 100644 index 00000000..50b1d4c3 --- /dev/null +++ b/tests/templates/kuttl/kraft-kerberos/00-assert.yaml.j2 @@ -0,0 +1,10 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +{% if lookup('env', 'VECTOR_AGGREGATOR') %} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: vector-aggregator-discovery +{% endif %} diff --git a/tests/templates/kuttl/kraft-kerberos/00-install-vector-aggregator-discovery-configmap.yaml.j2 b/tests/templates/kuttl/kraft-kerberos/00-install-vector-aggregator-discovery-configmap.yaml.j2 new file mode 100644 index 00000000..2d6a0df5 --- /dev/null +++ b/tests/templates/kuttl/kraft-kerberos/00-install-vector-aggregator-discovery-configmap.yaml.j2 @@ -0,0 +1,9 @@ +{% if lookup('env', 'VECTOR_AGGREGATOR') %} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: vector-aggregator-discovery +data: + ADDRESS: {{ lookup('env', 'VECTOR_AGGREGATOR') }} +{% endif %} diff --git a/tests/templates/kuttl/kraft-kerberos/00-patch-ns.yaml.j2 b/tests/templates/kuttl/kraft-kerberos/00-patch-ns.yaml.j2 new file mode 100644 index 00000000..67185acf --- /dev/null +++ b/tests/templates/kuttl/kraft-kerberos/00-patch-ns.yaml.j2 @@ -0,0 +1,9 @@ +{% if test_scenario['values']['openshift'] == 'true' %} +# see https://github.com/stackabletech/issues/issues/566 +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: kubectl patch namespace $NAMESPACE -p '{"metadata":{"labels":{"pod-security.kubernetes.io/enforce":"privileged"}}}' + timeout: 120 +{% endif %} diff --git a/tests/templates/kuttl/kraft-kerberos/00-rbac.yaml.j2 b/tests/templates/kuttl/kraft-kerberos/00-rbac.yaml.j2 new file mode 100644 index 00000000..7ee61d23 --- /dev/null +++ b/tests/templates/kuttl/kraft-kerberos/00-rbac.yaml.j2 @@ -0,0 +1,29 @@ +--- +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: test-role +rules: +{% if test_scenario['values']['openshift'] == "true" %} + - apiGroups: ["security.openshift.io"] + resources: ["securitycontextconstraints"] + resourceNames: ["privileged"] + verbs: ["use"] +{% endif %} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: test-sa +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: test-rb +subjects: + - kind: ServiceAccount + name: test-sa +roleRef: + kind: Role + name: test-role + apiGroup: rbac.authorization.k8s.io diff --git a/tests/templates/kuttl/kraft-kerberos/01-assert.yaml.j2 b/tests/templates/kuttl/kraft-kerberos/01-assert.yaml.j2 new file mode 100644 index 00000000..d34c1c63 --- /dev/null +++ b/tests/templates/kuttl/kraft-kerberos/01-assert.yaml.j2 @@ -0,0 +1,14 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 300 +{% if test_scenario['values']['kerberos-backend'] == 'mit' %} +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: krb5-kdc +status: + readyReplicas: 1 + replicas: 1 +{% endif %} diff --git a/tests/templates/kuttl/kraft-kerberos/01-install-krb5-kdc.yaml.j2 b/tests/templates/kuttl/kraft-kerberos/01-install-krb5-kdc.yaml.j2 new file mode 100644 index 00000000..69ceec81 --- /dev/null +++ b/tests/templates/kuttl/kraft-kerberos/01-install-krb5-kdc.yaml.j2 @@ -0,0 +1,146 @@ +{% if test_scenario['values']['kerberos-backend'] == 'mit' %} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: krb5-kdc +spec: + selector: + matchLabels: + app: krb5-kdc + template: + metadata: + labels: + app: krb5-kdc + spec: + serviceAccountName: test-sa + initContainers: + - name: init + image: oci.stackable.tech/sdp/krb5:{{ test_scenario['values']['krb5'] }}-stackable0.0.0-dev + args: + - sh + - -euo + - pipefail + - -c + - | + test -e /var/kerberos/krb5kdc/principal || kdb5_util create -s -P asdf + kadmin.local get_principal -terse root/admin || kadmin.local add_principal -pw asdf root/admin + # stackable-secret-operator principal must match the keytab specified in the SecretClass + kadmin.local get_principal -terse stackable-secret-operator || kadmin.local add_principal -e aes256-cts-hmac-sha384-192:normal -pw asdf stackable-secret-operator + env: + - name: KRB5_CONFIG + value: /stackable/config/krb5.conf + volumeMounts: + - mountPath: /stackable/config + name: config + - mountPath: /var/kerberos/krb5kdc + name: data + containers: + - name: kdc + image: oci.stackable.tech/sdp/krb5:{{ test_scenario['values']['krb5'] }}-stackable0.0.0-dev + args: + - krb5kdc + - -n + env: + - name: KRB5_CONFIG + value: /stackable/config/krb5.conf + volumeMounts: + - mountPath: /stackable/config + name: config + - mountPath: /var/kerberos/krb5kdc + name: data +# Root permissions required on Openshift to bind to privileged port numbers +{% if test_scenario['values']['openshift'] == "true" %} + securityContext: + runAsUser: 0 +{% endif %} + - name: kadmind + image: oci.stackable.tech/sdp/krb5:{{ test_scenario['values']['krb5'] }}-stackable0.0.0-dev + args: + - kadmind + - -nofork + env: + - name: KRB5_CONFIG + value: /stackable/config/krb5.conf + volumeMounts: + - mountPath: /stackable/config + name: config + - mountPath: /var/kerberos/krb5kdc + name: data +# Root permissions required on Openshift to bind to privileged port numbers +{% if test_scenario['values']['openshift'] == "true" %} + securityContext: + runAsUser: 0 +{% endif %} + - name: client + image: oci.stackable.tech/sdp/krb5:{{ test_scenario['values']['krb5'] }}-stackable0.0.0-dev + tty: true + stdin: true + env: + - name: KRB5_CONFIG + value: /stackable/config/krb5.conf + volumeMounts: + - mountPath: /stackable/config + name: config + volumes: + - name: config + configMap: + name: krb5-kdc + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: krb5-kdc +spec: + selector: + app: krb5-kdc + ports: + - name: kadmin + port: 749 + - name: kdc + port: 88 + - name: kdc-udp + port: 88 + protocol: UDP +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: krb5-kdc +data: + krb5.conf: | + [logging] + default = STDERR + kdc = STDERR + admin_server = STDERR + # default = FILE:/var/log/krb5libs.log + # kdc = FILE:/var/log/krb5kdc.log + # admin_server = FILE:/vaggr/log/kadmind.log + [libdefaults] + dns_lookup_realm = false + ticket_lifetime = 24h + renew_lifetime = 7d + forwardable = true + rdns = false + default_realm = {{ test_scenario['values']['kerberos-realm'] }} + spake_preauth_groups = edwards25519 + [realms] + {{ test_scenario['values']['kerberos-realm'] }} = { + acl_file = /stackable/config/kadm5.acl + disable_encrypted_timestamp = false + } + [domain_realm] + .cluster.local = {{ test_scenario['values']['kerberos-realm'] }} + cluster.local = {{ test_scenario['values']['kerberos-realm'] }} + kadm5.acl: | + root/admin *e + stackable-secret-operator *e +{% endif %} diff --git a/tests/templates/kuttl/kraft-kerberos/02-create-kerberos-secretclass.yaml.j2 b/tests/templates/kuttl/kraft-kerberos/02-create-kerberos-secretclass.yaml.j2 new file mode 100644 index 00000000..04ae9a63 --- /dev/null +++ b/tests/templates/kuttl/kraft-kerberos/02-create-kerberos-secretclass.yaml.j2 @@ -0,0 +1,72 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: | + kubectl apply -n $NAMESPACE -f - < 0 %} + custom: "{{ test_scenario['values']['kafka-kraft'].split(',')[1] }}" + productVersion: "{{ test_scenario['values']['kafka-kraft'].split(',')[0] }}" +{% else %} + productVersion: "{{ test_scenario['values']['kafka-kraft'] }}" +{% endif %} + pullPolicy: IfNotPresent + clusterConfig: + # KRaft: metadata is managed by the controllers role, no ZooKeeper involved. + metadataManager: kraft + authentication: + - authenticationClass: kerberos-auth-$NAMESPACE + tls: + # Kerberos requires the use of server and internal TLS! + serverSecretClass: tls +{% if lookup('env', 'VECTOR_AGGREGATOR') %} + vectorAggregatorConfigMapName: vector-aggregator-discovery +{% endif %} + controllers: + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + gracefulShutdownTimeout: 30s # speed up tests + roleGroups: + default: + # 3 controller replicas so that this test actually exercises inter-controller + # (Raft) Kerberos-authenticated traffic on the CONTROLLER listener, not just + # broker-to-controller traffic. + replicas: 3 + brokers: + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + brokerListenerClass: {{ test_scenario['values']['broker-listener-class'] }} + # bootstrap-listener-class is orthogonal to this test's focus on Kerberos over the + # CONTROLLER listener (that axis is already covered by the plain `kerberos` test + # case), so it is pinned here rather than parameterized as a test dimension. + bootstrapListenerClass: cluster-internal + gracefulShutdownTimeout: 30s # speed up tests + roleGroups: + default: + replicas: 3 + EOF diff --git a/tests/templates/kuttl/kraft-kerberos/30-access-kafka.txt.j2 b/tests/templates/kuttl/kraft-kerberos/30-access-kafka.txt.j2 new file mode 100644 index 00000000..50a31864 --- /dev/null +++ b/tests/templates/kuttl/kraft-kerberos/30-access-kafka.txt.j2 @@ -0,0 +1,131 @@ +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: access-kafka +spec: + template: + spec: + serviceAccountName: test-sa + containers: + - name: access-kafka +{% if test_scenario['values']['kafka-kraft'].find(",") > 0 %} + image: {{ test_scenario['values']['kafka-kraft'].split(',')[1] }} +{% else %} + image: oci.stackable.tech/sdp/kafka:{{ test_scenario['values']['kafka-kraft'] }}-stackable0.0.0-dev +{% endif %} + command: + - /bin/bash + - /tmp/script/script.sh + env: + - name: KRB5_CONFIG + value: /stackable/kerberos/krb5.conf + - name: KAFKA_OPTS + value: -Djava.security.krb5.conf=/stackable/kerberos/krb5.conf + - name: KAFKA + valueFrom: + configMapKeyRef: + name: test-kafka + key: KAFKA + volumeMounts: + - name: script + mountPath: /tmp/script + - mountPath: /stackable/tls-ca-cert-mount + name: tls-ca-cert-mount + - name: kerberos + mountPath: /stackable/kerberos + volumes: + - name: script + configMap: + name: access-kafka-script + - name: kerberos + ephemeral: + volumeClaimTemplate: + metadata: + annotations: + secrets.stackable.tech/class: kerberos-$NAMESPACE + secrets.stackable.tech/scope: service=access-kafka + secrets.stackable.tech/kerberos.service.names: developer + spec: + storageClassName: secrets.stackable.tech + accessModes: + - ReadWriteOnce + resources: + requests: + storage: "1" + - name: tls-ca-cert-mount + ephemeral: + volumeClaimTemplate: + metadata: + annotations: + secrets.stackable.tech/class: tls + secrets.stackable.tech/scope: pod + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: "1" + storageClassName: secrets.stackable.tech + volumeMode: Filesystem + securityContext: + fsGroup: 1000 + restartPolicy: OnFailure +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: access-kafka-script +data: + script.sh: | + set -euxo pipefail + + export KCAT_CONFIG=/stackable/kcat.conf + TOPIC=test-topic + CONSUMER_GROUP=test-consumer-group + + echo -e -n "\ + metadata.broker.list=$KAFKA\n\ + auto.offset.reset=beginning\n\ + security.protocol=SASL_SSL\n\ + ssl.ca.location=/stackable/tls-ca-cert-mount/ca.crt\n\ + sasl.kerberos.keytab=/stackable/kerberos/keytab\n\ + sasl.kerberos.service.name=kafka\n\ + sasl.kerberos.principal=developer/access-kafka.$NAMESPACE.svc.cluster.local@{{ test_scenario['values']['kerberos-realm'] }}\n\ + sasl.mechanism=GSSAPI\n\ + " > $KCAT_CONFIG + + cat $KCAT_CONFIG + + sent_message="Hello Stackable!" + + echo $sent_message | kcat \ + -t $TOPIC \ + -P + + echo Sent message: \"$sent_message\" + + # Explicit numeric offset (not "-o stored"/"auto.offset.reset=beginning"): the bundled kcat's + # librdkafka (1.7.0) mis-detects broker feature support against Kafka >=4.0 brokers, which + # dropped old low-numbered API versions (KIP-896). It matches ApiVersions by exact version + # instead of range, so it wrongly reports the ListOffsets logical-offset query as unsupported + # ("Failed to query logical offset BEGINNING: Local: Required feature not supported by + # broker") even though the broker supports it fine -- see + # https://github.com/confluentinc/librdkafka/issues/4948. This is unrelated to SASL/Kerberos: + # authentication succeeds either way. The topic is freshly created and this is the only + # message ever produced to it, so offset 0 is always the message we just sent. + received_message=$(kcat \ + -G $CONSUMER_GROUP \ + -o 0 \ + -e \ + $TOPIC) + + echo Received message: \"$received_message\" + + if [ "$received_message" = "$sent_message" ]; then + echo "Test passed" + exit 0 + else + echo "Test failed" + exit 1 + fi diff --git a/tests/templates/kuttl/kraft-kerberos/30-access-kafka.yaml b/tests/templates/kuttl/kraft-kerberos/30-access-kafka.yaml new file mode 100644 index 00000000..eecc0f08 --- /dev/null +++ b/tests/templates/kuttl/kraft-kerberos/30-access-kafka.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + # We need to replace $NAMESPACE (by KUTTL) + - script: envsubst '$NAMESPACE' < 30-access-kafka.txt | kubectl apply -n $NAMESPACE -f - diff --git a/tests/templates/kuttl/kraft-kerberos/30-assert.yaml b/tests/templates/kuttl/kraft-kerberos/30-assert.yaml new file mode 100644 index 00000000..edc6c317 --- /dev/null +++ b/tests/templates/kuttl/kraft-kerberos/30-assert.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 600 +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: access-kafka +status: + succeeded: 1 diff --git a/tests/templates/kuttl/kraft-kerberos/README.md b/tests/templates/kuttl/kraft-kerberos/README.md new file mode 100644 index 00000000..a85e47b8 --- /dev/null +++ b/tests/templates/kuttl/kraft-kerberos/README.md @@ -0,0 +1,9 @@ +# Kraft + Kerberos test + +Proves that a KRaft-mode Kafka cluster (`spec.controllers` present, no ZooKeeper) can be +secured with Kerberos authentication (`spec.clusterConfig.authentication` referencing a +Kerberos `AuthenticationClass`) end to end: controllers form a quorum, brokers join, and a +client can authenticate via GSSAPI to produce/consume a message. + +This bundles the KRaft cluster setup from `smoke-kraft` with the KDC deployment, +`SecretClass`/`AuthenticationClass` and produce/consume job from `kerberos`. diff --git a/tests/test-definition.yaml b/tests/test-definition.yaml index 3cb4633f..086ebbfc 100644 --- a/tests/test-definition.yaml +++ b/tests/test-definition.yaml @@ -83,6 +83,14 @@ tests: dimensions: - kafka-kraft - openshift + - name: kraft-kerberos + dimensions: + - kafka-kraft + - krb5 + - kerberos-realm + - kerberos-backend + - openshift + - broker-listener-class - name: smoke dimensions: - kafka