Skip to content

Commit 58c0f87

Browse files
committed
refactor: cover smoke 14-assert via config map unit tests for tls scenarios
1 parent d6c0c26 commit 58c0f87

2 files changed

Lines changed: 324 additions & 10 deletions

File tree

rust/operator-binary/src/crd/authentication.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,28 @@ impl DereferencedAuthenticationClasses {
131131
dereferenced_authentication_classes: vec![],
132132
}
133133
}
134+
135+
/// USE ONLY IN TESTS! Builds a [`DereferencedAuthenticationClasses`] holding a single TLS
136+
/// `AuthenticationClass`, mirroring the `use-client-auth-tls` kuttl scenario. This exercises the
137+
/// client-mTLS branches of [`crate::crd::security::ZookeeperSecurity`] (secure client port and
138+
/// `ssl.clientAuth=need`), including the case where server TLS is otherwise disabled and the
139+
/// auth class alone turns TLS on.
140+
#[cfg(test)]
141+
pub fn new_for_tests_with_tls_client_auth() -> Self {
142+
use stackable_operator::crd::authentication::tls;
143+
144+
let auth_class = core::v1alpha1::AuthenticationClass::new(
145+
"zk-client-auth-tls",
146+
core::v1alpha1::AuthenticationClassSpec {
147+
provider: core::v1alpha1::AuthenticationClassProvider::Tls(
148+
tls::v1alpha1::AuthenticationProvider {
149+
client_cert_secret_class: Some("zk-client-auth-secret".to_owned()),
150+
},
151+
),
152+
},
153+
);
154+
DereferencedAuthenticationClasses {
155+
dereferenced_authentication_classes: vec![auth_class],
156+
}
157+
}
134158
}

rust/operator-binary/src/zk_controller.rs

Lines changed: 300 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -438,11 +438,20 @@ pub(crate) mod test_support {
438438
/// tests can assert on validation errors.
439439
pub fn try_validate(
440440
zk: &v1alpha1::ZookeeperCluster,
441+
) -> Result<ValidatedCluster, super::validate::Error> {
442+
try_validate_with_auth(zk, DereferencedAuthenticationClasses::new_for_tests())
443+
}
444+
445+
/// Runs the real validate step with caller-supplied (dereferenced) AuthenticationClasses, so
446+
/// tests can exercise the client-mTLS matrix.
447+
pub fn try_validate_with_auth(
448+
zk: &v1alpha1::ZookeeperCluster,
449+
authentication_classes: DereferencedAuthenticationClasses,
441450
) -> Result<ValidatedCluster, super::validate::Error> {
442451
validate(
443452
zk,
444453
&DereferencedObjects {
445-
authentication_classes: DereferencedAuthenticationClasses::new_for_tests(),
454+
authentication_classes,
446455
},
447456
&operator_environment(),
448457
)
@@ -452,18 +461,33 @@ pub(crate) mod test_support {
452461
pub fn validated_cluster(zk: &v1alpha1::ZookeeperCluster) -> ValidatedCluster {
453462
try_validate(zk).expect("validate should succeed for the test fixture")
454463
}
464+
465+
/// Runs the real validate step with a single TLS client-auth `AuthenticationClass`, mirroring
466+
/// the `use-client-auth-tls` kuttl scenario.
467+
pub fn validated_cluster_with_client_auth(zk: &v1alpha1::ZookeeperCluster) -> ValidatedCluster {
468+
try_validate_with_auth(
469+
zk,
470+
DereferencedAuthenticationClasses::new_for_tests_with_tls_client_auth(),
471+
)
472+
.expect("validate should succeed for the test fixture")
473+
}
455474
}
456475

457476
#[cfg(test)]
458477
mod tests {
459-
use std::str::FromStr;
478+
use std::{collections::BTreeSet, str::FromStr};
460479

461480
use stackable_operator::{
462481
k8s_openapi::api::core::v1::ConfigMap, v2::types::operator::RoleGroupName,
463482
};
464483

465484
use super::*;
466-
use crate::zk_controller::test_support::{cluster_info, minimal_zk, validated_cluster};
485+
use crate::zk_controller::{
486+
test_support::{
487+
cluster_info, minimal_zk, validated_cluster, validated_cluster_with_client_auth,
488+
},
489+
validate::ValidatedCluster,
490+
};
467491

468492
#[test]
469493
fn test_default_config() {
@@ -536,9 +560,10 @@ mod tests {
536560

537561
#[test]
538562
fn test_seeded_operator_defaults() {
539-
// These values are seeded directly by the ConfigMap builder and must stay
540-
// byte-identical (pinned by the kuttl snapshot
541-
// `tests/templates/kuttl/smoke/14-assert.yaml.j2`).
563+
// These values are seeded directly by the ConfigMap builder and must stay byte-identical.
564+
// This test (together with the TLS x client-auth matrix tests below) is the source of truth
565+
// for the rendered `zoo.cfg` / `security.properties`; the kuttl smoke keeps only the
566+
// live-cluster readiness/status checks.
542567
let zookeeper_yaml = r#"
543568
apiVersion: zookeeper.stackable.tech/v1alpha1
544569
kind: ZookeeperCluster
@@ -731,15 +756,280 @@ mod tests {
731756
assert!(cm.contains_key("vector.yaml"));
732757
}
733758

759+
// ---------------------------------------------------------------------------------------------
760+
// TLS x client-auth matrix (candidate #1 in unit-tests.md).
761+
//
762+
// These four tests own what the kuttl smoke `14-assert` `zoo.cfg` heredoc used to assert across
763+
// the `use-server-tls` x `use-client-auth-tls` scenario matrix: the exact set of `zoo.cfg`
764+
// properties and the ports/`ssl.*` lines that vary with TLS and client mTLS. Asserting the full
765+
// key set (not just `contains`) catches accidentally added *or* removed properties. Client mTLS
766+
// is injected via `validated_cluster_with_client_auth`, which the previous `new_for_tests()`
767+
// path could not reach.
768+
// ---------------------------------------------------------------------------------------------
769+
770+
/// The `zoo.cfg` keys that are always present regardless of the TLS/client-auth matrix
771+
/// (operator-injected defaults + quorum TLS, which is always on).
772+
fn base_zoo_cfg_keys() -> BTreeSet<String> {
773+
[
774+
"admin.serverPort",
775+
"authProvider.x509",
776+
"clientPort",
777+
"dataDir",
778+
"initLimit",
779+
"metricsProvider.className",
780+
"metricsProvider.httpPort",
781+
"serverCnxnFactory",
782+
"ssl.quorum.clientAuth",
783+
"ssl.quorum.hostnameVerification",
784+
"ssl.quorum.keyStore.location",
785+
"ssl.quorum.trustStore.location",
786+
"sslQuorum",
787+
"syncLimit",
788+
"tickTime",
789+
]
790+
.into_iter()
791+
.map(str::to_owned)
792+
.collect()
793+
}
794+
795+
/// The `zoo.cfg` property keys, excluding the replica-dependent `server.<myid>` quorum entries
796+
/// (those are asserted separately in `test_server_lines_use_myid_offset_across_rolegroups`).
797+
fn zoo_cfg_keys(cm: &ConfigMap) -> BTreeSet<String> {
798+
cm.data
799+
.as_ref()
800+
.unwrap()
801+
.get("zoo.cfg")
802+
.unwrap()
803+
.lines()
804+
.filter_map(|line| line.split_once('=').map(|(k, _)| k.to_owned()))
805+
.filter(|k| !k.starts_with("server."))
806+
.collect()
807+
}
808+
809+
/// TLS off, client-auth off: insecure client port, no server-`ssl.*` lines, no
810+
/// `client.portUnification`, no `ssl.clientAuth`.
811+
#[test]
812+
fn test_matrix_no_tls_no_client_auth() {
813+
let cm = build_config_map(
814+
r#"
815+
apiVersion: zookeeper.stackable.tech/v1alpha1
816+
kind: ZookeeperCluster
817+
metadata:
818+
name: simple-zookeeper
819+
spec:
820+
image:
821+
productVersion: "3.9.5"
822+
clusterConfig:
823+
tls:
824+
serverSecretClass: null
825+
servers:
826+
roleGroups:
827+
default:
828+
replicas: 3
829+
"#,
830+
);
831+
let zoo_cfg = cm.data.as_ref().unwrap().get("zoo.cfg").unwrap();
832+
833+
assert_eq!(zoo_cfg_keys(&cm), base_zoo_cfg_keys());
834+
assert!(zoo_cfg.contains("clientPort=2181"), "{zoo_cfg}");
835+
assert!(!zoo_cfg.contains("client.portUnification"), "{zoo_cfg}");
836+
assert!(!zoo_cfg.contains("ssl.clientAuth"), "{zoo_cfg}");
837+
assert!(!zoo_cfg.contains("ssl.keyStore.location"), "{zoo_cfg}");
838+
}
839+
840+
/// TLS on (default), client-auth off: secure client port, server-`ssl.*` lines and
841+
/// `client.portUnification`, but no `ssl.clientAuth`.
842+
#[test]
843+
fn test_matrix_server_tls_no_client_auth() {
844+
let cm = build_config_map(
845+
r#"
846+
apiVersion: zookeeper.stackable.tech/v1alpha1
847+
kind: ZookeeperCluster
848+
metadata:
849+
name: simple-zookeeper
850+
spec:
851+
image:
852+
productVersion: "3.9.5"
853+
servers:
854+
roleGroups:
855+
default:
856+
replicas: 3
857+
"#,
858+
);
859+
let zoo_cfg = cm.data.as_ref().unwrap().get("zoo.cfg").unwrap();
860+
861+
let mut expected = base_zoo_cfg_keys();
862+
expected.extend(
863+
[
864+
"client.portUnification",
865+
"ssl.hostnameVerification",
866+
"ssl.keyStore.location",
867+
"ssl.trustStore.location",
868+
]
869+
.into_iter()
870+
.map(str::to_owned),
871+
);
872+
assert_eq!(zoo_cfg_keys(&cm), expected);
873+
assert!(zoo_cfg.contains("clientPort=2282"), "{zoo_cfg}");
874+
assert!(zoo_cfg.contains("client.portUnification=true"), "{zoo_cfg}");
875+
assert!(
876+
zoo_cfg.contains("ssl.keyStore.location=/stackable/server_tls/keystore.p12"),
877+
"{zoo_cfg}"
878+
);
879+
assert!(!zoo_cfg.contains("ssl.clientAuth"), "{zoo_cfg}");
880+
}
881+
882+
/// TLS on, client-auth on: as above plus `ssl.clientAuth=need`.
883+
#[test]
884+
fn test_matrix_server_tls_and_client_auth() {
885+
let zk = minimal_zk(
886+
r#"
887+
apiVersion: zookeeper.stackable.tech/v1alpha1
888+
kind: ZookeeperCluster
889+
metadata:
890+
name: simple-zookeeper
891+
spec:
892+
image:
893+
productVersion: "3.9.5"
894+
servers:
895+
roleGroups:
896+
default:
897+
replicas: 3
898+
"#,
899+
);
900+
let cm = config_map_for(&validated_cluster_with_client_auth(&zk), "default");
901+
let zoo_cfg = cm.data.as_ref().unwrap().get("zoo.cfg").unwrap();
902+
903+
let mut expected = base_zoo_cfg_keys();
904+
expected.extend(
905+
[
906+
"client.portUnification",
907+
"ssl.clientAuth",
908+
"ssl.hostnameVerification",
909+
"ssl.keyStore.location",
910+
"ssl.trustStore.location",
911+
]
912+
.into_iter()
913+
.map(str::to_owned),
914+
);
915+
assert_eq!(zoo_cfg_keys(&cm), expected);
916+
assert!(zoo_cfg.contains("clientPort=2282"), "{zoo_cfg}");
917+
assert!(zoo_cfg.contains("ssl.clientAuth=need"), "{zoo_cfg}");
918+
}
919+
920+
/// Client-auth on while server TLS is explicitly disabled: the client `AuthenticationClass`
921+
/// alone turns TLS on (secure port, `ssl.*` lines, `client.portUnification`) and adds
922+
/// `ssl.clientAuth=need`. This cross term was previously unreachable in unit tests.
923+
#[test]
924+
fn test_matrix_client_auth_forces_tls_without_server_secret_class() {
925+
let zk = minimal_zk(
926+
r#"
927+
apiVersion: zookeeper.stackable.tech/v1alpha1
928+
kind: ZookeeperCluster
929+
metadata:
930+
name: simple-zookeeper
931+
spec:
932+
image:
933+
productVersion: "3.9.5"
934+
clusterConfig:
935+
tls:
936+
serverSecretClass: null
937+
servers:
938+
roleGroups:
939+
default:
940+
replicas: 3
941+
"#,
942+
);
943+
let cm = config_map_for(&validated_cluster_with_client_auth(&zk), "default");
944+
let zoo_cfg = cm.data.as_ref().unwrap().get("zoo.cfg").unwrap();
945+
946+
let mut expected = base_zoo_cfg_keys();
947+
expected.extend(
948+
[
949+
"client.portUnification",
950+
"ssl.clientAuth",
951+
"ssl.hostnameVerification",
952+
"ssl.keyStore.location",
953+
"ssl.trustStore.location",
954+
]
955+
.into_iter()
956+
.map(str::to_owned),
957+
);
958+
assert_eq!(zoo_cfg_keys(&cm), expected);
959+
assert!(zoo_cfg.contains("clientPort=2282"), "{zoo_cfg}");
960+
assert!(zoo_cfg.contains("client.portUnification=true"), "{zoo_cfg}");
961+
assert!(zoo_cfg.contains("ssl.clientAuth=need"), "{zoo_cfg}");
962+
}
963+
964+
/// `server.<myid>` quorum lines are rendered into the per-rolegroup `zoo.cfg` with
965+
/// `myidOffset` applied and colons escaped, aggregated across *all* server role groups. Mirrors
966+
/// the kuttl smoke `14-assert` `server.N` lines (primary offset 10, secondary offset 20).
967+
#[test]
968+
fn test_server_lines_use_myid_offset_across_rolegroups() {
969+
let zk = minimal_zk(
970+
r#"
971+
apiVersion: zookeeper.stackable.tech/v1alpha1
972+
kind: ZookeeperCluster
973+
metadata:
974+
name: test-zk
975+
spec:
976+
image:
977+
productVersion: "3.9.5"
978+
clusterConfig:
979+
tls:
980+
serverSecretClass: null
981+
servers:
982+
roleGroups:
983+
primary:
984+
replicas: 2
985+
config:
986+
myidOffset: 10
987+
secondary:
988+
replicas: 1
989+
config:
990+
myidOffset: 20
991+
"#,
992+
);
993+
let validated = validated_cluster(&zk);
994+
// The `server.N` lines are identical in every rolegroup's `zoo.cfg`; inspect the primary's.
995+
let cm = config_map_for(&validated, "primary");
996+
let zoo_cfg = cm.data.as_ref().unwrap().get("zoo.cfg").unwrap();
997+
998+
// Colons are escaped by the Java-properties writer (`\:`); non-TLS client port 2181.
999+
assert!(
1000+
zoo_cfg.contains(
1001+
"server.10=test-zk-server-primary-0.test-zk-server-primary-headless.default.svc.cluster.local\\:2888\\:3888;2181"
1002+
),
1003+
"{zoo_cfg}"
1004+
);
1005+
assert!(
1006+
zoo_cfg.contains("server.11=test-zk-server-primary-1."),
1007+
"{zoo_cfg}"
1008+
);
1009+
assert!(
1010+
zoo_cfg.contains("server.20=test-zk-server-secondary-0."),
1011+
"{zoo_cfg}"
1012+
);
1013+
// Exactly the three expected quorum entries, no more.
1014+
assert_eq!(
1015+
zoo_cfg.lines().filter(|l| l.starts_with("server.")).count(),
1016+
3,
1017+
"{zoo_cfg}"
1018+
);
1019+
}
1020+
7341021
fn build_config_map(zookeeper_yaml: &str) -> ConfigMap {
735-
let zookeeper = minimal_zk(zookeeper_yaml);
736-
let validated_cluster = validated_cluster(&zookeeper);
737-
let role_group_name = RoleGroupName::from_str("default").expect("valid role group name");
1022+
config_map_for(&validated_cluster(&minimal_zk(zookeeper_yaml)), "default")
1023+
}
1024+
1025+
/// Builds the rolegroup `ConfigMap` for the named server role group of a validated cluster.
1026+
fn config_map_for(validated_cluster: &ValidatedCluster, role_group: &str) -> ConfigMap {
1027+
let role_group_name = RoleGroupName::from_str(role_group).expect("valid role group name");
7381028
let rolegroup_config =
7391029
&validated_cluster.role_group_configs[&ZookeeperRole::Server][&role_group_name];
7401030

7411031
config_map::build_server_rolegroup_config_map(
742-
&validated_cluster,
1032+
validated_cluster,
7431033
&cluster_info(),
7441034
&role_group_name,
7451035
rolegroup_config,

0 commit comments

Comments
 (0)