Skip to content
Open
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 @@ -33,6 +33,7 @@ All notable changes to this project will be documented in this file.
whereas previously the operator's values always took precedence ([#753]).
- BREAKING (behaviour): Keys of the `spark-env.sh` `configOverrides` must be valid shell
identifiers (matching `[a-zA-Z_][a-zA-Z0-9_]*`) and are now rejected if they are not ([#761]).
- Make operations infallible where dependent on static inputs ([#766]).

### Fixed

Expand Down Expand Up @@ -67,6 +68,7 @@ All notable changes to this project will be documented in this file.
[#754]: https://github.com/stackabletech/spark-k8s-operator/pull/754
[#757]: https://github.com/stackabletech/spark-k8s-operator/pull/757
[#761]: https://github.com/stackabletech/spark-k8s-operator/pull/761
[#766]: https://github.com/stackabletech/spark-k8s-operator/pull/766

## [26.7.0] - 2026-07-21

Expand Down
38 changes: 23 additions & 15 deletions rust/operator-binary/src/connect/controller/build/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,9 @@ pub fn executor_pod_template(
container
.add_env_vars(container_env)
.add_volume_mount(VOLUME_MOUNT_NAME_CONFIG.as_ref(), VOLUME_MOUNT_PATH_CONFIG)
.context(AddVolumeMountSnafu)?
.expect("The mount paths are statically defined and there should be no duplicates.")
.add_volume_mount(VOLUME_MOUNT_NAME_LOG.as_ref(), VOLUME_MOUNT_PATH_LOG)
.context(AddVolumeMountSnafu)?
.add_volume_mounts(s3_volume_mounts)
.context(AddVolumeMountSnafu)?;
.expect("The mount paths are statically defined and there should be no duplicates.");

let metadata = ObjectMetaBuilder::new()
.with_labels(recommended_labels_for_role_resources(
Expand Down Expand Up @@ -136,8 +134,6 @@ pub fn executor_pod_template(
.build(),
)
.context(AddVolumeSnafu)?
.add_volumes(s3_volumes)
.context(AddVolumeSnafu)?
// This is needed for shared enpryDir volumes with other containers like the truststore
// init container.
.security_context(
Expand All @@ -146,21 +142,17 @@ pub fn executor_pod_template(
.build(),
);

// S3: Add truststore init container for S3 endpoint communication with TLS.
if let Some(truststore_init_container) = resolved_s3
.truststore_init_container(resolved_product_image.clone())
.context(TrustStoreInitContainerSnafu)?
{
template.add_init_container(truststore_init_container);
}

// Add custom log4j config map volumes if configured. The mount path is a constant, so the
// mount cannot collide with the other operator-managed mounts and adding it is infallible.
// It is added before the S3 mounts below, which are derived from user input. Adding the
// volume stays fallible, because the volume is built from computed arguments.
if let Some(cm_name) = config.log_config_map() {
container
.add_volume_mount(
VOLUME_MOUNT_NAME_LOG_CONFIG.as_ref(),
VOLUME_MOUNT_PATH_LOG_CONFIG,
)
.context(AddVolumeMountSnafu)?;
.expect("The mount paths are statically defined and there should be no duplicates.");

template
.add_volume(
Expand All @@ -171,6 +163,22 @@ pub fn executor_pod_template(
.context(AddVolumeSnafu)?;
}

// S3: Add volumes and mounts (credentials and certificates) needed for accessing S3 buckets.
// Their names embed the user-supplied SecretClass names, so they can collide with the
// operator-managed ones and these adds stay fallible.
container
.add_volume_mounts(s3_volume_mounts)
.context(AddVolumeMountSnafu)?;
template.add_volumes(s3_volumes).context(AddVolumeSnafu)?;

// S3: Add truststore init container for S3 endpoint communication with TLS.
if let Some(truststore_init_container) = resolved_s3
.truststore_init_container(resolved_product_image.clone())
.context(TrustStoreInitContainerSnafu)?
{
template.add_init_container(truststore_init_container);
}

template.add_container(container.build());

// Vector log-aggregation sidecar (symmetric with the server), added when the executor enables
Expand Down
22 changes: 15 additions & 7 deletions rust/operator-binary/src/connect/controller/build/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,17 +241,18 @@ pub(crate) fn build_stateful_set(
.add_container_port(HTTP, CONNECT_UI_PORT.into())
.add_env_vars(container_env)
.add_volume_mount(VOLUME_MOUNT_NAME_CONFIG.as_ref(), VOLUME_MOUNT_PATH_CONFIG)
.context(AddVolumeMountSnafu)?
.expect("The mount paths are statically defined and there should be no duplicates.")
.add_volume_mount(VOLUME_MOUNT_NAME_LOG.as_ref(), VOLUME_MOUNT_PATH_LOG)
.context(AddVolumeMountSnafu)?
.expect("The mount paths are statically defined and there should be no duplicates.")
.add_volume_mount(LISTENER_VOLUME_NAME.as_ref(), LISTENER_VOLUME_DIR)
.context(AddVolumeMountSnafu)?
.add_volume_mounts(s3_volume_mounts)
.context(AddVolumeMountSnafu)?
.expect("The mount paths are statically defined and there should be no duplicates.")
.readiness_probe(probe())
.liveness_probe(probe());

// Add custom log4j config map volumes if configured
// Add custom log4j config map volumes if configured. The mount path is a constant, so the
// mount cannot collide with the other operator-managed mounts and adding it is infallible.
// It is added before the S3 mounts below, which are derived from user input. Adding the
// volume stays fallible, because the volume is built from computed arguments.
if let Some(cm_name) = config.log_config_map() {
pb.add_volume(
VolumeBuilder::new(VOLUME_MOUNT_NAME_LOG_CONFIG.as_ref())
Expand All @@ -265,9 +266,16 @@ pub(crate) fn build_stateful_set(
VOLUME_MOUNT_NAME_LOG_CONFIG.as_ref(),
VOLUME_MOUNT_PATH_LOG_CONFIG,
)
.context(AddVolumeMountSnafu)?;
.expect("The mount paths are statically defined and there should be no duplicates.");
}

// S3: Add mounts (credentials and certificates) needed for accessing S3 buckets. Their names
// embed the user-supplied SecretClass names, so they can collide with the operator-managed
// ones and this add stays fallible.
container
.add_volume_mounts(s3_volume_mounts)
.context(AddVolumeMountSnafu)?;

pb.add_container(container.build());

if let Some(vector_log_config) = &validated.server_logging.vector_container {
Expand Down
9 changes: 0 additions & 9 deletions rust/operator-binary/src/crd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,18 +73,9 @@ pub enum Error {
#[snafu(display("object has no namespace associated"))]
NoNamespace,

#[snafu(display("object defines no deploy mode"))]
ObjectHasNoDeployMode,

#[snafu(display("object defines no application artifact"))]
ObjectHasNoArtifact,

#[snafu(display("object has no name"))]
ObjectHasNoName,

#[snafu(display("application has no Spark image"))]
NoSparkImage,

#[snafu(display("failed to convert java heap config to unit [{unit}]"))]
FailedToConvertJavaHeap {
source: stackable_operator::memory::Error,
Expand Down
25 changes: 13 additions & 12 deletions rust/operator-binary/src/crd/template_spec.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
//! This module provides the SparkApplicationTemplateSpec CRD definition.

use std::{num::ParseIntError, str::ParseBoolError};
use std::{
num::ParseIntError,
str::{FromStr, ParseBoolError},
};

use regex::Regex;
use serde::{Deserialize, Serialize};
use snafu::{ResultExt, Snafu};
use stackable_operator::{
constant,
kube::{Api, CustomResource, ResourceExt, api::ListParams},
schemars::{self, JsonSchema},
versioned::versioned,
Expand All @@ -18,9 +22,6 @@ use crate::crd::template_merger::deep_merge;
#[strum_discriminants(derive(IntoStaticStr))]
#[allow(clippy::enum_variant_names)]
pub enum Error {
#[snafu(display("failed to build template merge options from application annotations"))]
BuildMergeTemplateOptions,

#[snafu(display(
"invalid index value [{value}] for template names. value must be non negative integer"
))]
Expand All @@ -29,9 +30,6 @@ pub enum Error {
value: String,
},

#[snafu(display("invalid regex for template names annotation"))]
InvalidAnnotationTemplateNameRx { source: regex::Error },

#[snafu(display("invalid value [{value}] for annotation [{name}]"))]
InvalidAnnotationBooleanValue {
source: ParseBoolError,
Expand Down Expand Up @@ -128,7 +126,7 @@ enum TemplateApplyStrategy {

// This annotation regex selects the template names to apply.
// The <index> value determines the merge order.
const ANNO_TEMPLATE_NAME_RX: &str = "^spark-application\\.template\\.(?P<index>\\d+)\\.name$";
constant!(ANNO_TEMPLATE_NAME_RX: Regex = "^spark-application\\.template\\.(?P<index>\\d+)\\.name$");
// A boolean that enable/disables template merging.
const ANNO_TEMPLATE_MERGE: &str = "spark-application.template.merge";
// This annotation instructs the operator when to update patched applications.
Expand Down Expand Up @@ -166,12 +164,9 @@ impl TryFrom<&super::v1alpha1::SparkApplication> for MergeTemplateOptions {

// Extract template indexes and names.
// Sort by indexes and discard them.
let template_name_rx =
Regex::new(ANNO_TEMPLATE_NAME_RX).context(InvalidAnnotationTemplateNameRxSnafu)?;

let mut template_index_name = vec![];
for (k, v) in annos.iter() {
if let Some(caps) = template_name_rx.captures(k) {
if let Some(caps) = ANNO_TEMPLATE_NAME_RX.captures(k) {
let index = caps["index"].parse::<u8>().context(
InvalidAnnotationTemplateIndexSnafu {
value: caps["index"].to_string(),
Expand Down Expand Up @@ -376,6 +371,12 @@ mod tests {

use super::*;

#[test]
fn test_constants() {
// Test that dereferencing the constants does not panic.
let _ = *ANNO_TEMPLATE_NAME_RX;
}

#[test]
fn try_from_parses_annotations_and_sorts_template_names() {
let spark_application =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use stackable_operator::{
crd::listener,
v2::types::{
kubernetes::{ListenerClassName, ListenerName},
operator::RoleName,
operator::{ClusterName, RoleName},
},
};

Expand Down Expand Up @@ -39,18 +39,50 @@ pub(crate) fn build_group_listener(
)
}

/// The returned ListenerName is a lowercase RFC 1035 label name (checked by a unit test).
pub(crate) fn group_listener_name(
validated: &validate::ValidatedSparkHistoryServer,
role_name: &RoleName,
) -> ListenerName {
const _: () = assert!(
ClusterName::MAX_LENGTH + 1 /* dash */ + RoleName::MAX_LENGTH <= ListenerName::MAX_LENGTH,
"The string `<cluster_name>-<role_name>` must not exceed the limit of Listener names."
);
// Both halves are RFC 1123 labels joined by a dash, which is a valid RFC 1123 subdomain.
let _ = ClusterName::IS_RFC_1123_SUBDOMAIN_NAME;
let _ = RoleName::IS_RFC_1123_LABEL_NAME;

ListenerName::from_str(&format!(
"{cluster}-{role}",
cluster = validated.name,
role = role_name
))
.expect(
"the group listener name is a valid ListenerName, because a ClusterName is at most 40 \
characters long and a RoleName is a RFC 1123 label of at most 63 characters, so the \
joined name is a RFC 1123 DNS subdomain within the length limit",
)
.expect("The role listener name is a valid Listener name.")
}

#[cfg(test)]
mod tests {
use super::*;
use crate::history::controller::{
build::test_support::minimal_validated_cluster, validate::NODE_ROLE_NAME,
};

#[test]
fn group_listener_name_is_rfc_1035_label_name() {
// Every ClusterName is a valid RFC 1035 label name, so we use just some string with maximum
// length.
let _ = ClusterName::IS_RFC_1035_LABEL_NAME;
let mut validated = minimal_validated_cluster();
validated.name = ClusterName::from_str(&"a".repeat(ClusterName::MAX_LENGTH))
.expect("is a valid ClusterName");

// The history server has a single role.
let group_listener_name = group_listener_name(&validated, &NODE_ROLE_NAME);
assert!(
stackable_operator::validation::is_lowercase_rfc_1035_label(
group_listener_name.as_ref()
)
.is_ok()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -197,18 +197,20 @@ pub(crate) fn build_stateful_set(
.add_container_port("http", HISTORY_UI_PORT.into())
.add_container_port("metrics", METRICS_PORT.into())
.add_env_vars(merged_env)
.add_volume_mounts(log_dir.volume_mounts())
.context(AddVolumeMountSnafu)?
.add_volume_mount(VOLUME_MOUNT_NAME_CONFIG.as_ref(), VOLUME_MOUNT_PATH_CONFIG)
.context(AddVolumeMountSnafu)?
.expect("The mount paths are statically defined and there should be no duplicates.")
.add_volume_mount(
VOLUME_MOUNT_NAME_LOG_CONFIG.as_ref(),
VOLUME_MOUNT_PATH_LOG_CONFIG,
)
.context(AddVolumeMountSnafu)?
.expect("The mount paths are statically defined and there should be no duplicates.")
.add_volume_mount(VOLUME_MOUNT_NAME_LOG.as_ref(), VOLUME_MOUNT_PATH_LOG)
.context(AddVolumeMountSnafu)?
.expect("The mount paths are statically defined and there should be no duplicates.")
.add_volume_mount(LISTENER_VOLUME_NAME.as_ref(), LISTENER_VOLUME_DIR)
.expect("The mount paths are statically defined and there should be no duplicates.")
// The log dir mount names embed the user-supplied SecretClass names, so they can collide
// with the operator-managed ones and this add stays fallible.
.add_volume_mounts(log_dir.volume_mounts())
.context(AddVolumeMountSnafu)?
.build();

Expand Down
Loading
Loading