Skip to content

Commit 93f09bd

Browse files
committed
feat: openlineage.tls now also installs the server CA if necesary
1 parent b85328a commit 93f09bd

10 files changed

Lines changed: 163 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ All notable changes to this project will be documented in this file.
88

99
- BREAKING: Add required CLI argument and env var to set the image repository used to construct final product image names: `IMAGE_REPOSITORY` (`--image-repository`), eg. `oci.example.org/my/namespace` ([#684]).
1010
- Support for Spark `4.1.2` ([#708])
11-
- Add `spec.openLineage` to enable OpenLineage lineage emission: injects the OpenLineage Spark listener, HTTP transport (endpoint built from the `host` and `port` fields), a stable job name, and the required `--add-opens` JVM flag ([#XXXX]).
11+
- Add `spec.openLineage` to enable OpenLineage lineage emission: injects the OpenLineage Spark listener, HTTP transport (endpoint built from the `host` and `port` fields), a stable job name, and the required `--add-opens` JVM flag. When the connection configures TLS server verification against a `secretClass` CA, that certificate is mounted into the driver and added to its trust store ([#XXXX]).
1212

1313
### Fixed
1414

docs/modules/spark-k8s/pages/usage-guide/openlineage.adoc

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ spec:
6262
----
6363

6464
The transport scheme is `https` when `tls.verification.server` is configured, otherwise `http`.
65-
Explicit certificate handling beyond selecting the scheme will be added later.
65+
When the connection verifies the server against a `secretClass` CA (as in the commented `tls` block
66+
above), the operator mounts that SecretClass certificate into the driver and adds it to the driver's
67+
trust store, so the OpenLineage listener trusts the backend's certificate.
6668

6769
You can still override any of the injected values — including the full `spark.openlineage.transport.url` — through `sparkConf`.
6870

rust/operator-binary/src/config/jvm.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use stackable_operator::crd::s3;
1+
use stackable_operator::crd::{openlineage, s3};
22

33
use crate::crd::{
44
Error,
@@ -26,6 +26,7 @@ pub fn construct_extra_java_options(
2626
s3_conn: &Option<s3::v1alpha1::ConnectionSpec>,
2727
log_dir: &Option<ResolvedLogDir>,
2828
product_version: &str,
29+
open_lineage_conn: Option<&openlineage::ResolvedOpenLineageConnection>,
2930
) -> Result<(String, String), Error> {
3031
// Note (@sbernauer): As of 2025-03-04, we did not set any heap related JVM arguments, so I
3132
// kept the implementation as is. We can always re-visit this as needed.
@@ -34,7 +35,7 @@ pub fn construct_extra_java_options(
3435
"-Djava.security.properties={VOLUME_MOUNT_PATH_LOG_CONFIG}/{JVM_SECURITY_PROPERTIES_FILE}"
3536
)];
3637

37-
if tls_secret_names(s3_conn, log_dir).is_some() {
38+
if tls_secret_names(s3_conn, log_dir, open_lineage_conn).is_some() {
3839
jvm_args.extend([
3940
format!("-Djavax.net.ssl.trustStore={STACKABLE_TRUST_STORE}/truststore.p12"),
4041
format!("-Djavax.net.ssl.trustStorePassword={STACKABLE_TLS_STORE_PASSWORD}"),
@@ -102,7 +103,7 @@ mod tests {
102103
"#,
103104
);
104105
let (driver_extra_java_options, executor_extra_java_options) =
105-
construct_extra_java_options(&spark_app, &None, &None, "4.1.2").unwrap();
106+
construct_extra_java_options(&spark_app, &None, &None, "4.1.2", None).unwrap();
106107

107108
assert_eq!(
108109
driver_extra_java_options,
@@ -140,7 +141,7 @@ mod tests {
140141
"#,
141142
);
142143
let (driver_extra_java_options, executor_extra_java_options) =
143-
construct_extra_java_options(&spark_app, &None, &None, "4.1.2").unwrap();
144+
construct_extra_java_options(&spark_app, &None, &None, "4.1.2", None).unwrap();
144145

145146
assert_eq!(
146147
driver_extra_java_options,
@@ -194,7 +195,7 @@ mod tests {
194195
) {
195196
let spark_app = spark_app_from_yaml(&yaml_template.replace("PLACEHOLDER", product_version));
196197
let (driver_extra_java_options, executor_extra_java_options) =
197-
construct_extra_java_options(&spark_app, &None, &None, product_version).unwrap();
198+
construct_extra_java_options(&spark_app, &None, &None, product_version, None).unwrap();
198199

199200
assert_eq!(
200201
driver_extra_java_options.contains(OPENLINEAGE_ADD_OPENS),

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

Lines changed: 86 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ impl v1alpha1::SparkApplication {
337337
logdir: &Option<ResolvedLogDir>,
338338
log_config_map: Option<&str>,
339339
requested_secret_lifetime: &Duration,
340+
open_lineage_conn: Option<&openlineage::ResolvedOpenLineageConnection>,
340341
) -> Result<Vec<Volume>, Error> {
341342
// Collect the volumes in a map keyed by name to avoid duplicates.
342343
// Duplicates can happen when the the S3 credentials volume and the history server log directory use the same secret class.
@@ -417,7 +418,7 @@ impl v1alpha1::SparkApplication {
417418
.build(),
418419
);
419420
}
420-
if let Some(cert_secrets) = tlscerts::tls_secret_names(s3conn, logdir) {
421+
if let Some(cert_secrets) = tlscerts::tls_secret_names(s3conn, logdir, open_lineage_conn) {
421422
result.insert(
422423
STACKABLE_TRUST_STORE_NAME.to_string(),
423424
VolumeBuilder::new(STACKABLE_TRUST_STORE_NAME)
@@ -470,6 +471,7 @@ impl v1alpha1::SparkApplication {
470471
&self,
471472
s3conn: &Option<s3::v1alpha1::ConnectionSpec>,
472473
logdir: &Option<ResolvedLogDir>,
474+
open_lineage_conn: Option<&openlineage::ResolvedOpenLineageConnection>,
473475
) -> Vec<VolumeMount> {
474476
let mut tmpl_mounts = vec![
475477
VolumeMount {
@@ -484,7 +486,8 @@ impl v1alpha1::SparkApplication {
484486
},
485487
];
486488

487-
tmpl_mounts = self.add_common_volume_mounts(tmpl_mounts, s3conn, logdir, false);
489+
tmpl_mounts =
490+
self.add_common_volume_mounts(tmpl_mounts, s3conn, logdir, false, open_lineage_conn);
488491

489492
if let Some(CommonConfiguration {
490493
config:
@@ -510,6 +513,7 @@ impl v1alpha1::SparkApplication {
510513
s3conn: &Option<s3::v1alpha1::ConnectionSpec>,
511514
logdir: &Option<ResolvedLogDir>,
512515
logging_enabled: bool,
516+
open_lineage_conn: Option<&openlineage::ResolvedOpenLineageConnection>,
513517
) -> Vec<VolumeMount> {
514518
if self.spec.image.is_some() {
515519
mounts.push(VolumeMount {
@@ -568,7 +572,7 @@ impl v1alpha1::SparkApplication {
568572
..VolumeMount::default()
569573
});
570574
}
571-
if let Some(cert_secrets) = tlscerts::tls_secret_names(s3conn, logdir) {
575+
if let Some(cert_secrets) = tlscerts::tls_secret_names(s3conn, logdir, open_lineage_conn) {
572576
mounts.push(VolumeMount {
573577
name: STACKABLE_TRUST_STORE_NAME.into(),
574578
mount_path: STACKABLE_TRUST_STORE.into(),
@@ -606,17 +610,18 @@ impl v1alpha1::SparkApplication {
606610
let name = self.metadata.name.clone().context(ObjectHasNoNameSnafu)?;
607611

608612
// Commands needed to build the p12 trust store from the secret class certs configured for
609-
// S3 connections.
610-
let build_truststore_commands = match tlscerts::tls_secret_names(s3conn, log_dir) {
611-
Some(cert_secrets) => {
612-
let mut build_truststore_str =
613-
vec![tlscerts::convert_system_trust_store_to_pkcs12()];
614-
build_truststore_str
615-
.extend(cert_secrets.into_iter().map(tlscerts::import_truststore));
616-
format!("{};", build_truststore_str.join(" && "))
617-
}
618-
None => "".to_string(),
619-
};
613+
// S3 connections, the log directory, and the OpenLineage backend connection.
614+
let build_truststore_commands =
615+
match tlscerts::tls_secret_names(s3conn, log_dir, open_lineage_conn) {
616+
Some(cert_secrets) => {
617+
let mut build_truststore_str =
618+
vec![tlscerts::convert_system_trust_store_to_pkcs12()];
619+
build_truststore_str
620+
.extend(cert_secrets.into_iter().map(tlscerts::import_truststore));
621+
format!("{};", build_truststore_str.join(" && "))
622+
}
623+
None => "".to_string(),
624+
};
620625

621626
let mut submit_cmd = vec![
622627
format!(
@@ -698,7 +703,13 @@ impl v1alpha1::SparkApplication {
698703
}
699704

700705
let (driver_extra_java_options, executor_extra_java_options) =
701-
construct_extra_java_options(self, s3conn, log_dir, product_version)?;
706+
construct_extra_java_options(
707+
self,
708+
s3conn,
709+
log_dir,
710+
product_version,
711+
open_lineage_conn,
712+
)?;
702713
submit_cmd.extend(vec![
703714
format!("--conf spark.driver.extraJavaOptions=\"{driver_extra_java_options}\""),
704715
format!("--conf spark.executor.extraJavaOptions=\"{executor_extra_java_options}\""),
@@ -845,6 +856,7 @@ impl v1alpha1::SparkApplication {
845856
&self,
846857
s3conn: &Option<s3::v1alpha1::ConnectionSpec>,
847858
logdir: &Option<ResolvedLogDir>,
859+
open_lineage_conn: Option<&openlineage::ResolvedOpenLineageConnection>,
848860
) -> Vec<EnvVar> {
849861
let mut e: Vec<EnvVar> = self.spec.env.clone();
850862

@@ -877,7 +889,7 @@ impl v1alpha1::SparkApplication {
877889
value_from: None,
878890
});
879891
}
880-
if tlscerts::tls_secret_names(s3conn, logdir).is_some() {
892+
if tlscerts::tls_secret_names(s3conn, logdir, open_lineage_conn).is_some() {
881893
e.push(EnvVar {
882894
name: "STACKABLE_TLS_STORE_PASSWORD".to_string(),
883895
value: Some(STACKABLE_TLS_STORE_PASSWORD.to_string()),
@@ -1627,7 +1639,7 @@ spec:
16271639
"#})
16281640
.unwrap();
16291641

1630-
let got = spark_application.spark_job_volume_mounts(&None, &None);
1642+
let got = spark_application.spark_job_volume_mounts(&None, &None, None);
16311643

16321644
let expected = vec![
16331645
VolumeMount {
@@ -1875,6 +1887,63 @@ spec:
18751887
);
18761888
}
18771889

1890+
/// The OpenLineage connection's `caCert.secretClass` must be wired into the driver truststore,
1891+
/// so the OpenLineage listener's HTTPS POST to the backend trusts the secret-operator cert.
1892+
/// Without this the `https` scheme above is unusable (handshake fails against the default JVM
1893+
/// truststore).
1894+
#[test]
1895+
fn test_openlineage_tls_secret_class_wired_into_truststore() {
1896+
let yaml = indoc! {r#"
1897+
---
1898+
apiVersion: spark.stackable.tech/v1alpha1
1899+
kind: SparkApplication
1900+
metadata:
1901+
name: spark-examples
1902+
namespace: default
1903+
spec:
1904+
mode: cluster
1905+
mainApplicationFile: test.py
1906+
sparkImage:
1907+
productVersion: 1.2.3
1908+
openLineage:
1909+
connection:
1910+
inline:
1911+
host: marquez
1912+
port: 5000
1913+
tls:
1914+
verification:
1915+
server:
1916+
caCert:
1917+
secretClass: marquez-ca
1918+
"#};
1919+
let command = build_command_with_openlineage(yaml, "4.1.2");
1920+
1921+
// The mounted secret-operator truststore for `marquez-ca` is imported into the shared
1922+
// PKCS12 truststore.
1923+
assert!(command.contains(&format!(
1924+
"{STACKABLE_MOUNT_PATH_TLS}/marquez-ca/truststore.p12"
1925+
)));
1926+
// The driver + executor JVMs are pointed at that truststore.
1927+
assert_eq!(
1928+
command
1929+
.matches(&format!(
1930+
"-Djavax.net.ssl.trustStore={STACKABLE_TRUST_STORE}/truststore.p12"
1931+
))
1932+
.count(),
1933+
2,
1934+
);
1935+
}
1936+
1937+
/// Without an OpenLineage TLS `secretClass` (and no S3/logdir), no truststore is built and the
1938+
/// driver JVM gets no truststore options.
1939+
#[test]
1940+
fn test_openlineage_without_tls_has_no_truststore() {
1941+
let command = build_command_with_openlineage(OPENLINEAGE_ENABLED_APP, "4.1.2");
1942+
1943+
assert!(!command.contains("-Djavax.net.ssl.trustStore="));
1944+
assert!(!command.contains(STACKABLE_MOUNT_PATH_TLS));
1945+
}
1946+
18781947
#[rstest]
18791948
#[case::explicit_app_name(
18801949
indoc! {r#"

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use stackable_operator::{
2828
fragment::Fragment,
2929
merge::{Atomic, Merge},
3030
},
31-
crd::s3,
31+
crd::{openlineage, s3},
3232
k8s_openapi::{api::core::v1::VolumeMount, apimachinery::pkg::api::resource::Quantity},
3333
product_logging::{self, spec::Logging},
3434
schemars::{self, JsonSchema},
@@ -167,9 +167,16 @@ impl RoleConfig {
167167
spark_application: &v1alpha1::SparkApplication,
168168
s3conn: &Option<s3::v1alpha1::ConnectionSpec>,
169169
logdir: &Option<ResolvedLogDir>,
170+
open_lineage_conn: Option<&openlineage::ResolvedOpenLineageConnection>,
170171
) -> Vec<VolumeMount> {
171172
let volume_mounts = self.volume_mounts.clone().into();
172-
spark_application.add_common_volume_mounts(volume_mounts, s3conn, logdir, true)
173+
spark_application.add_common_volume_mounts(
174+
volume_mounts,
175+
s3conn,
176+
logdir,
177+
true,
178+
open_lineage_conn,
179+
)
173180
}
174181
}
175182

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use stackable_operator::{
44
commons::tls_verification::{
55
CaCert, Tls, TlsClientDetails, TlsServerVerification, TlsVerification,
66
},
7-
crd::s3,
7+
crd::{openlineage, s3},
88
};
99

1010
use crate::crd::{
@@ -33,9 +33,32 @@ pub fn tls_secret_name(s3conn: &s3::v1alpha1::ConnectionSpec) -> Option<&str> {
3333
None
3434
}
3535

36+
/// Extracts the SecretClass name backing the CA cert for a resolved OpenLineage connection, if the
37+
/// connection verifies the server's TLS certificate against a SecretClass CA. Mirrors
38+
/// [`tls_secret_name`] for S3 connections — both carry a flattened [`TlsClientDetails`].
39+
pub fn openlineage_tls_secret_name(
40+
conn: &openlineage::ResolvedOpenLineageConnection,
41+
) -> Option<&str> {
42+
if let TlsClientDetails {
43+
tls:
44+
Some(Tls {
45+
verification:
46+
TlsVerification::Server(TlsServerVerification {
47+
ca_cert: CaCert::SecretClass(secret_name),
48+
}),
49+
}),
50+
} = &conn.tls
51+
{
52+
return Some(secret_name);
53+
}
54+
55+
None
56+
}
57+
3658
pub fn tls_secret_names<'a>(
3759
s3conn: &'a Option<s3::v1alpha1::ConnectionSpec>,
3860
logdir: &'a Option<ResolvedLogDir>,
61+
open_lineage_conn: Option<&'a openlineage::ResolvedOpenLineageConnection>,
3962
) -> Option<Vec<&'a str>> {
4063
// Ensure there are no duplicate secret names.
4164
let mut names = BTreeSet::new();
@@ -49,6 +72,10 @@ pub fn tls_secret_names<'a>(
4972
{
5073
names.insert(secret_name);
5174
}
75+
76+
if let Some(secret_name) = open_lineage_conn.and_then(openlineage_tls_secret_name) {
77+
names.insert(secret_name);
78+
}
5279
if names.is_empty() {
5380
None
5481
} else {

rust/operator-binary/src/spark_k8s_controller.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ pub async fn reconcile(
166166
.await
167167
.context(ApplyRoleBindingSnafu)?;
168168

169-
let env_vars = spark_application.env(opt_s3conn, logdir);
169+
let env_vars = spark_application.env(opt_s3conn, logdir, opt_open_lineage_conn);
170170

171171
let driver_config = spark_application
172172
.driver_config()

rust/operator-binary/src/spark_k8s_controller/build/pod.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ fn init_containers(
5151
let spark_application = &validated.spark_application;
5252
let s3conn = &validated.cluster_config.s3_connection;
5353
let logdir = &validated.cluster_config.log_dir;
54+
let open_lineage_conn = validated.cluster_config.open_lineage_connection.as_ref();
5455
let spark_image = &validated.resolved_product_image;
5556
let mut jcb = new_container_builder(&SparkContainer::Job.to_container_name());
5657
let job_container = match &spark_application.spec.image {
@@ -153,7 +154,7 @@ fn init_containers(
153154
let mut tcb = new_container_builder(&SparkContainer::Tls.to_container_name());
154155
let mut args = Vec::new();
155156

156-
let tls_container = match tlscerts::tls_secret_names(s3conn, logdir) {
157+
let tls_container = match tlscerts::tls_secret_names(s3conn, logdir, open_lineage_conn) {
157158
Some(cert_secrets) => {
158159
args.push(tlscerts::convert_system_trust_store_to_pkcs12());
159160
for cert_secret in cert_secrets {
@@ -207,16 +208,22 @@ pub(crate) fn pod_template(
207208
let spark_application = &validated.spark_application;
208209
let s3conn = &validated.cluster_config.s3_connection;
209210
let logdir = &validated.cluster_config.log_dir;
211+
let open_lineage_conn = validated.cluster_config.open_lineage_connection.as_ref();
210212
let spark_image = &validated.resolved_product_image;
211213
let container_name = SparkContainer::Spark.to_string();
212214
let mut cb = new_container_builder(&SparkContainer::Spark.to_container_name());
213215
let merged_env = spark_application.merged_env(role.clone(), env);
214216

215-
cb.add_volume_mounts(config.volume_mounts(spark_application, s3conn, logdir))
216-
.context(AddVolumeMountSnafu)?
217-
.add_env_vars(merged_env)
218-
.resources(config.resources.clone().into())
219-
.image_from_product_image(spark_image);
217+
cb.add_volume_mounts(config.volume_mounts(
218+
spark_application,
219+
s3conn,
220+
logdir,
221+
open_lineage_conn,
222+
))
223+
.context(AddVolumeMountSnafu)?
224+
.add_env_vars(merged_env)
225+
.resources(config.resources.clone().into())
226+
.image_from_product_image(spark_image);
220227

221228
if config.logging.enable_vector_agent {
222229
cb.add_env_var(

0 commit comments

Comments
 (0)