Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ All notable changes to this project will be documented in this file.
`image.pullPolicy` of a SparkConnectServer now covers its truststore init container and its
executors. With the default `Always`, these containers pull on every start instead of using the
node's image cache ([#764]).
- The submit Job and the history server no longer print S3 credentials to the container log ([#796]).

[#721]: https://github.com/stackabletech/spark-k8s-operator/pull/721
[#727]: https://github.com/stackabletech/spark-k8s-operator/pull/727
Expand All @@ -108,6 +109,7 @@ All notable changes to this project will be documented in this file.
[#789]: https://github.com/stackabletech/spark-k8s-operator/pull/789
[#790]: https://github.com/stackabletech/spark-k8s-operator/pull/790
[#795]: https://github.com/stackabletech/spark-k8s-operator/pull/795
[#796]: https://github.com/stackabletech/spark-k8s-operator/pull/796

## [26.7.0] - 2026-07-21

Expand Down
66 changes: 60 additions & 6 deletions rust/operator-binary/src/crd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -598,10 +598,6 @@ impl v1alpha1::SparkApplication {
};

let mut submit_cmd = vec![
format!(
"containerdebug --output={VOLUME_MOUNT_PATH_LOG}/containerdebug-state.json --loop &"
),
build_truststore_commands,
"/stackable/spark/bin/spark-submit".to_string(),
"--verbose".to_string(),
"--master k8s://https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT_HTTPS}"
Expand Down Expand Up @@ -776,7 +772,16 @@ impl v1alpha1::SparkApplication {

submit_cmd.extend(self.spec.args.clone());

Ok(vec![submit_cmd.join(" ")])
Ok(vec![
format!(
"containerdebug --output={VOLUME_MOUNT_PATH_LOG}/containerdebug-state.json --loop &"
),
build_truststore_commands,
// xtrace prints expanded credentials, `--verbose` prints the config redacted
"set +x".to_string(),
"echo 'Running \"spark-submit [...]\", which is not shown as it can contain credentials'".to_string(),
submit_cmd.join(" "),
])
}

/// The base environment for the submit, driver and executor containers: the user-defined
Expand Down Expand Up @@ -1095,13 +1100,15 @@ mod tests {
commons::{
affinity::StackableAffinity,
resources::{CpuLimits, MemoryLimits, NoRuntimeLimits, Resources},
secret_class::SecretClassVolume,
tls_verification::TlsClientDetails,
},
product_logging::spec::Logging,
versioned::test_utils::RoundtripTestData,
};

use super::*;
use crate::crd::roles::SparkStorageConfig;
use crate::{crd::roles::SparkStorageConfig, test_support::assert_not_traced};

#[test]
fn test_constants() {
Expand Down Expand Up @@ -1654,6 +1661,53 @@ spec:
assert_eq!(got, expected);
}

#[test]
fn s3_credentials_are_not_traced() {
let spark_application: v1alpha1::SparkApplication =
stackable_operator::utils::yaml_from_str_singleton_map(indoc! {r#"
apiVersion: spark.stackable.tech/v1alpha1
kind: SparkApplication
metadata:
name: spark-example
namespace: default
spec:
mode: cluster
mainApplicationFile: test.py
sparkImage:
productVersion: 1.2.3
"#})
.expect("invalid test SparkApplication YAML");
let resolved_product_image = spark_application
.spec
.spark_image
.resolve(
"spark-k8s",
"oci.example.org/sdp",
&"0.0.0-dev"
.parse()
.expect("static semantic version must parse"),
)
.expect("the product image resolves");
let s3conn = Some(s3::v1alpha1::ConnectionSpec {
host: "my-s3".to_string().try_into().expect("valid host"),
port: None,
access_style: Default::default(),
credentials: Some(SecretClassVolume {
secret_class: "s3-credentials".to_string(),
scope: None,
}),
tls: TlsClientDetails { tls: None },
region: Default::default(),
});

let script = spark_application
.build_command(&s3conn, &None, &resolved_product_image)
.expect("the submit command can be built")
.join("\n");

assert_not_traced(&script, "fs.s3a.secret.key=");
}

#[rstest]
#[case::from_spark_image(None, "IfNotPresent")]
#[case::spark_conf_wins(Some("Never"), "Never")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,9 +274,13 @@ fn command_args(logdir: &ResolvedLogDir) -> Vec<String> {
let mut command = vec![];

if let Some(secret_dir) = logdir.credentials_mount_path() {
// xtrace (the x setting) would print the expanded S3 credentials to the container log.
command.extend(vec![
"set +x".to_string(),
"echo 'Exporting AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, which is not shown as it leaks the S3 credentials'".to_string(),
format!("export AWS_ACCESS_KEY_ID=\"$(cat {secret_dir}/{ACCESS_KEY_ID})\""),
format!("export AWS_SECRET_ACCESS_KEY=\"$(cat {secret_dir}/{SECRET_ACCESS_KEY})\""),
"set -x".to_string(),
]);
}

Expand All @@ -295,10 +299,17 @@ fn command_args(logdir: &ResolvedLogDir) -> Vec<String> {

#[cfg(test)]
mod tests {
use stackable_operator::k8s_openapi::api::core::v1::EnvVar;
use stackable_operator::{
commons::{secret_class::SecretClassVolume, tls_verification::TlsClientDetails},
crd::s3,
k8s_openapi::api::core::v1::EnvVar,
};

use super::*;
use crate::history::controller::build::test_support::minimal_validated_cluster;
use crate::{
crd::logdir::S3LogDir, history::controller::build::test_support::minimal_validated_cluster,
test_support::assert_not_traced,
};

#[test]
fn test_constants() {
Expand Down Expand Up @@ -360,4 +371,30 @@ mod tests {
assert_eq!(matching.len(), 1);
assert_eq!(matching[0].value.as_deref(), Some("overridden"));
}

#[test]
fn s3_credentials_are_not_traced() {
let log_dir = ResolvedLogDir::S3(S3LogDir {
bucket: s3::v1alpha1::ResolvedBucket {
bucket_name: "my-bucket".to_string(),
connection: s3::v1alpha1::ConnectionSpec {
host: "my-s3".to_string().try_into().expect("valid host"),
port: None,
access_style: Default::default(),
credentials: Some(SecretClassVolume {
secret_class: "s3-credentials".to_string(),
scope: None,
}),
tls: TlsClientDetails { tls: None },
region: Default::default(),
},
},
prefix: "prefix".to_string(),
});

let script = command_args(&log_dir).join("\n");

assert_not_traced(&script, "AWS_ACCESS_KEY_ID=");
assert_not_traced(&script, "AWS_SECRET_ACCESS_KEY=");
}
}
26 changes: 26 additions & 0 deletions rust/operator-binary/src/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,29 @@ where

assert_eq!(action, Action::await_change());
}

/// Asserts that `script` runs `set +x`, without a later `set -x`, before the first line
/// containing `needle`.
///
/// The containers run under `bash -x`, which prints every command with its variables
/// expanded.
/// Anything that reads a secret must have xtrace set to off.
///
/// Panics if no line contains `needle`.
pub fn assert_not_traced(script: &str, needle: &str) {
let lines: Vec<&str> = script.lines().map(str::trim).collect();
let position = lines
.iter()
.position(|line| line.contains(needle))
.unwrap_or_else(|| panic!("no line contains {needle:?} in:\n{script}"));
let last_toggle = lines[..position]
.iter()
.rev()
.find(|line| matches!(**line, "set +x" | "set -x"));

assert_eq!(
last_toggle,
Some(&"set +x"),
"{needle:?} is traced in:\n{script}"
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"containers": [
{
"args": [
"containerdebug --output=/stackable/log/containerdebug-state.json --loop & /stackable/spark/bin/spark-submit --verbose --master k8s://https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT_HTTPS} --deploy-mode cluster --name pyspark-pi --conf spark.kubernetes.driver.podTemplateFile=/stackable/spark/driver-pod-templates/template.yaml --conf spark.kubernetes.executor.podTemplateFile=/stackable/spark/executor-pod-templates/template.yaml --conf spark.kubernetes.driver.podTemplateContainerName=spark --conf spark.kubernetes.executor.podTemplateContainerName=spark --conf spark.kubernetes.namespace=__NAMESPACE__ --conf spark.kubernetes.driver.container.image=oci.stackable.tech/sdp/spark-k8s:4.2.0-stackable0.0.0-dev --conf spark.kubernetes.executor.container.image=oci.stackable.tech/sdp/spark-k8s:4.2.0-stackable0.0.0-dev --conf spark.driver.defaultJavaOptions=-Dlog4j.configurationFile=/stackable/log_config/log4j2.properties --conf spark.driver.extraClassPath=/stackable/spark/extra-jars/* --conf spark.executor.defaultJavaOptions=-Dlog4j.configurationFile=/stackable/log_config/log4j2.properties --conf spark.executor.extraClassPath=/stackable/spark/extra-jars/* --conf spark.driver.extraJavaOptions=\"-Djava.security.properties=/stackable/spark/conf/security.properties\" --conf spark.executor.extraJavaOptions=\"-Djava.security.properties=/stackable/spark/conf/security.properties\" --conf spark.metrics.conf.\\*.sink.prometheusServlet.class=org.apache.spark.metrics.sink.PrometheusServlet --conf spark.metrics.conf.\\*.sink.prometheusServlet.path=/metrics/prometheus --conf spark.ui.prometheus.enabled=true --conf spark.sql.streaming.metricsEnabled=true --conf \"spark.driver.cores=2\" --conf \"spark.driver.memory=640m\" --conf \"spark.executor.cores=2\" --conf \"spark.executor.instances=1\" --conf \"spark.executor.memory=640m\" --conf \"spark.kubernetes.container.image.pullPolicy=IfNotPresent\" --conf \"spark.kubernetes.driver.limit.cores=2\" --conf \"spark.kubernetes.driver.request.cores=1\" --conf \"spark.kubernetes.executor.limit.cores=2\" --conf \"spark.kubernetes.executor.request.cores=1\" --conf \"spark.kubernetes.memoryOverheadFactor=0.0\" local:///stackable/spark/examples/src/main/python/pi.py"
"containerdebug --output=/stackable/log/containerdebug-state.json --loop &\n\nset +x\necho 'Running \"spark-submit [...]\", which is not shown as it can contain credentials'\n/stackable/spark/bin/spark-submit --verbose --master k8s://https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT_HTTPS} --deploy-mode cluster --name pyspark-pi --conf spark.kubernetes.driver.podTemplateFile=/stackable/spark/driver-pod-templates/template.yaml --conf spark.kubernetes.executor.podTemplateFile=/stackable/spark/executor-pod-templates/template.yaml --conf spark.kubernetes.driver.podTemplateContainerName=spark --conf spark.kubernetes.executor.podTemplateContainerName=spark --conf spark.kubernetes.namespace=__NAMESPACE__ --conf spark.kubernetes.driver.container.image=oci.stackable.tech/sdp/spark-k8s:4.2.0-stackable0.0.0-dev --conf spark.kubernetes.executor.container.image=oci.stackable.tech/sdp/spark-k8s:4.2.0-stackable0.0.0-dev --conf spark.driver.defaultJavaOptions=-Dlog4j.configurationFile=/stackable/log_config/log4j2.properties --conf spark.driver.extraClassPath=/stackable/spark/extra-jars/* --conf spark.executor.defaultJavaOptions=-Dlog4j.configurationFile=/stackable/log_config/log4j2.properties --conf spark.executor.extraClassPath=/stackable/spark/extra-jars/* --conf spark.driver.extraJavaOptions=\"-Djava.security.properties=/stackable/spark/conf/security.properties\" --conf spark.executor.extraJavaOptions=\"-Djava.security.properties=/stackable/spark/conf/security.properties\" --conf spark.metrics.conf.\\*.sink.prometheusServlet.class=org.apache.spark.metrics.sink.PrometheusServlet --conf spark.metrics.conf.\\*.sink.prometheusServlet.path=/metrics/prometheus --conf spark.ui.prometheus.enabled=true --conf spark.sql.streaming.metricsEnabled=true --conf \"spark.driver.cores=2\" --conf \"spark.driver.memory=640m\" --conf \"spark.executor.cores=2\" --conf \"spark.executor.instances=1\" --conf \"spark.executor.memory=640m\" --conf \"spark.kubernetes.container.image.pullPolicy=IfNotPresent\" --conf \"spark.kubernetes.driver.limit.cores=2\" --conf \"spark.kubernetes.driver.request.cores=1\" --conf \"spark.kubernetes.executor.limit.cores=2\" --conf \"spark.kubernetes.executor.request.cores=1\" --conf \"spark.kubernetes.memoryOverheadFactor=0.0\" local:///stackable/spark/examples/src/main/python/pi.py"
],
"command": [
"/bin/bash",
Expand Down
Loading