From e8a291de7885e311f584af769a308cc1e58cb687 Mon Sep 17 00:00:00 2001 From: Benedikt Labrenz Date: Thu, 1 Oct 2026 13:58:37 +0200 Subject: [PATCH 1/5] add agent label utility --- crates/stackable-operator/src/v2/kvp/label.rs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/crates/stackable-operator/src/v2/kvp/label.rs b/crates/stackable-operator/src/v2/kvp/label.rs index a0d806557..383ae5401 100644 --- a/crates/stackable-operator/src/v2/kvp/label.rs +++ b/crates/stackable-operator/src/v2/kvp/label.rs @@ -28,6 +28,35 @@ pub fn recommended_labels_for_cluster_resources( ]) } +/// Creates the recommended labels for agent resources, like the agent Deployment. +pub fn recommended_labels_for_agent_resources( + cluster_name: &ClusterName, + product_name: &ProductName, + product_version: &ProductVersion, + operator_name: &OperatorName, + controller_name: &ControllerName, +) -> Labels { + Labels::from_iter([ + label_app_kubernetes_io_instance(cluster_name), + label_app_kubernetes_io_name(product_name), + label_app_kubernetes_io_version(product_version), + label_app_kubernetes_io_component_agent(), + label_app_kubernetes_io_managed_by(operator_name, controller_name), + label_stackable_tech_vendor(), + ]) +} + +/// Creates the agent selector. +/// +/// The returned labels are a subset of the recommended labels for agent resources. +pub fn agent_selector(cluster_name: &ClusterName, product_name: &ProductName) -> Labels { + Labels::from_iter([ + label_app_kubernetes_io_instance(cluster_name), + label_app_kubernetes_io_name(product_name), + label_app_kubernetes_io_component_agent(), + ]) +} + /// Creates the recommended labels for role resources, like discovery ConfigMaps. pub fn recommended_labels_for_role_resources( cluster_name: &ClusterName, @@ -155,6 +184,11 @@ pub fn label_app_kubernetes_io_component(role_name: &RoleName) -> Label { .expect("the value implements NameIsValidLabelValue and is therefore a valid label value") } +/// Creates the `app.kubernetes.io/component` label with the value `agent`. +pub fn label_app_kubernetes_io_component_agent() -> Label { + Label::component("agent").expect("\"agent\" is a valid label value") +} + /// Creates the `app.kubernetes.io/role-group` label with the given role group as value. pub fn label_app_kubernetes_io_role_group(role_group_name: &RoleGroupName) -> Label { Label::role_group(&role_group_name.to_label_value()) @@ -214,6 +248,52 @@ mod tests { assert_eq!(expected_labels, actual_labels.into()); } + #[test] + fn recommended_labels_for_agent_resources_produces_expected_labels() { + let actual_labels = recommended_labels_for_agent_resources( + &ClusterName::from_str_unsafe("cluster-name"), + &ProductName::from_str_unsafe("my-product"), + &ProductVersion::from_str_unsafe("1.0.0"), + &OperatorName::from_str_unsafe("my-operator"), + &ControllerName::from_str_unsafe("my-controller"), + ); + + let expected_labels: BTreeMap<_, _> = [ + ("app.kubernetes.io/component", "agent"), + ("app.kubernetes.io/instance", "cluster-name"), + ("app.kubernetes.io/managed-by", "my-operator_my-controller"), + ("app.kubernetes.io/name", "my-product"), + ("app.kubernetes.io/version", "1.0.0"), + ("stackable.tech/vendor", "Stackable"), + ] + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .into(); + + assert_eq!(expected_labels, actual_labels.into()); + } + + #[test] + fn agent_selector_is_subset_of_recommended_agent_labels() { + let cluster_name = ClusterName::from_str_unsafe("cluster-name"); + let product_name = ProductName::from_str_unsafe("my-product"); + + let agent_labels = recommended_labels_for_agent_resources( + &cluster_name, + &product_name, + &ProductVersion::from_str_unsafe("1.0.0"), + &OperatorName::from_str_unsafe("my-operator"), + &ControllerName::from_str_unsafe("my-controller"), + ); + + let agent_selector = agent_selector(&cluster_name, &product_name); + + assert!( + agent_selector + .iter() + .all(|selector| agent_labels.contains(selector)) + ); + } + #[test] fn recommended_labels_for_role_resources_produces_expected_labels() { let actual_labels = recommended_labels_for_role_resources( From 6ca45a0656f0a623061a3bfbff52c5ae113c352e Mon Sep 17 00:00:00 2001 From: Benedikt Labrenz Date: Thu, 1 Oct 2026 14:03:46 +0200 Subject: [PATCH 2/5] add TlsClientCredential enum as platform access utility --- crates/stackable-operator/src/commons/mod.rs | 1 + .../src/commons/platform_access/mod.rs | 3 + .../src/commons/platform_access/tls.rs | 135 ++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 crates/stackable-operator/src/commons/platform_access/mod.rs create mode 100644 crates/stackable-operator/src/commons/platform_access/tls.rs diff --git a/crates/stackable-operator/src/commons/mod.rs b/crates/stackable-operator/src/commons/mod.rs index cc4ab30e3..8da787e4d 100644 --- a/crates/stackable-operator/src/commons/mod.rs +++ b/crates/stackable-operator/src/commons/mod.rs @@ -6,6 +6,7 @@ pub mod cluster_operation; pub mod networking; pub mod opa; pub mod pdb; +pub mod platform_access; pub mod product_image_selection; pub mod random_secret_creation; pub mod rbac; diff --git a/crates/stackable-operator/src/commons/platform_access/mod.rs b/crates/stackable-operator/src/commons/platform_access/mod.rs new file mode 100644 index 000000000..7cedc76bf --- /dev/null +++ b/crates/stackable-operator/src/commons/platform_access/mod.rs @@ -0,0 +1,3 @@ +//! Credentials a product cluster grants the platform-access agent, and how to mount them. + +pub mod tls; diff --git a/crates/stackable-operator/src/commons/platform_access/tls.rs b/crates/stackable-operator/src/commons/platform_access/tls.rs new file mode 100644 index 000000000..9660e6ea7 --- /dev/null +++ b/crates/stackable-operator/src/commons/platform_access/tls.rs @@ -0,0 +1,135 @@ +use k8s_openapi::api::core::v1::{SecretVolumeSource, Volume, VolumeMount}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::{ + builder::pod::{ + PodBuilder, + container::ContainerBuilder, + volume::{ + SecretFormat, SecretOperatorVolumeSourceBuilder, VolumeBuilder, VolumeMountBuilder, + }, + }, + commons::secret_class::SecretClassVolumeProvisionParts, + constants::secret::SECRET_BASE_PATH, + v2::types::kubernetes::{SecretClassName, SecretName}, +}; + +/// Source of a TLS client certificate: a secret-operator SecretClass or a static Secret. +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum TlsClientCredential { + /// An AutoTLS SecretClass used to provision the certificate. + SecretClass(SecretClassName), + + /// A static Secret holding the certificate in the keys `tls.crt` and `tls.key` (PEM), e.g. as a + /// Secret of type `kubernetes.io/tls`. + Secret(SecretName), +} + +impl TlsClientCredential { + /// Adds the certificate volume to the Pod and mounts it into all given containers. + /// - TlsClientCredential::Secret mounts the Secret + /// - TlsClientCredential::Secret adds a secret-operator volume + pub fn add_volumes_and_mounts( + &self, + pod_builder: &mut PodBuilder, + container_builders: Vec<&mut ContainerBuilder>, + ) { + let (volumes, mounts) = self.volumes_and_mounts(); + pod_builder + .add_volumes(volumes) + .expect("The volume name is derived from the credential and should not collide."); + for container_builder in container_builders { + container_builder + .add_volume_mounts(mounts.clone()) + .expect("The mount path is derived from the credential and should not collide."); + } + } + + fn volumes_and_mounts(&self) -> (Vec, Vec) { + let volume_name = self.volume_name(); + let volume = match self { + Self::SecretClass(secret_class) => VolumeBuilder::new(&volume_name) + .ephemeral( + SecretOperatorVolumeSourceBuilder::new( + secret_class, + SecretClassVolumeProvisionParts::PublicPrivate, + ) + .with_pod_scope() + .with_format(SecretFormat::TlsPem) + .build() + .expect("the annotations are built from a valid SecretClass name"), + ) + .build(), + Self::Secret(secret) => Volume { + name: volume_name.clone(), + secret: Some(SecretVolumeSource { + secret_name: Some(secret.to_string()), + ..SecretVolumeSource::default() + }), + ..Volume::default() + }, + }; + let mount = VolumeMountBuilder::new(&volume_name, self.mount_path()).build(); + (vec![volume], vec![mount]) + } + + /// The directory containing the certificate as `tls.crt` and `tls.key` (PEM). + pub fn mount_path(&self) -> String { + format!("{SECRET_BASE_PATH}/{}", self.volume_name()) + } + + fn volume_name(&self) -> String { + match self { + Self::SecretClass(secret_class) => format!("{secret_class}-tls-cert"), + Self::Secret(secret) => format!("{secret}-tls-cert"), + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn secret_class_credential_is_provisioned_by_secret_operator() { + let credential = TlsClientCredential::SecretClass(SecretClassName::from_str_unsafe("tls")); + + let (volumes, mounts) = credential.volumes_and_mounts(); + let volumes = serde_json::to_value(volumes).expect("serializable"); + + assert_eq!(volumes[0]["name"], "tls-tls-cert"); + assert_eq!( + volumes[0]["ephemeral"]["volumeClaimTemplate"]["metadata"]["annotations"], + json!({ + "secrets.stackable.tech/class": "tls", + "secrets.stackable.tech/format": "tls-pem", + "secrets.stackable.tech/provision-parts": "public-private", + "secrets.stackable.tech/scope": "pod" + }) + ); + assert_eq!( + serde_json::to_value(mounts).expect("serializable"), + json!([{"mountPath": "/stackable/secrets/tls-tls-cert", "name": "tls-tls-cert"}]) + ); + } + + #[test] + fn static_secret_credential_is_mounted_directly() { + let credential = TlsClientCredential::Secret(SecretName::from_str_unsafe("my-cert")); + + let (volumes, mounts) = credential.volumes_and_mounts(); + + assert_eq!( + serde_json::to_value(volumes).expect("serializable"), + json!([{"name": "my-cert-tls-cert", "secret": {"secretName": "my-cert"}}]) + ); + assert_eq!( + serde_json::to_value(mounts).expect("serializable"), + json!([{"mountPath": "/stackable/secrets/my-cert-tls-cert", "name": "my-cert-tls-cert"}]) + ); + } +} From 7cb677e73bd218bcafb4ed4d4b459dda319fcbac Mon Sep 17 00:00:00 2001 From: Benedikt Labrenz Date: Thu, 1 Oct 2026 14:04:30 +0200 Subject: [PATCH 3/5] add changelog entry --- crates/stackable-operator/CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index ee60b2114..6b9787862 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Add utility for product agents ([#1290]): + - `TlsClientCredential`, enum for TLS-based platform access used for adding volumes and mounts. + - Labels and selectors for agents. + +[#1290]: https://github.com/stackabletech/operator-rs/pull/1290 + ## [0.119.0] - 2026-09-23 ### Removed From 7a0d956028f6382dee602ba370202d8b801b66e2 Mon Sep 17 00:00:00 2001 From: Benedikt Labrenz Date: Thu, 1 Oct 2026 14:12:45 +0200 Subject: [PATCH 4/5] fix cargo deny by upgrading yoke-derive --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 02573c11a..db3144c69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5215,9 +5215,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", From f3d2da737aa49086cbdaba806aaf2c8645251cfb Mon Sep 17 00:00:00 2001 From: Benedikt Labrenz Date: Thu, 1 Oct 2026 16:37:52 +0200 Subject: [PATCH 5/5] use hard-coded volume and mount names --- .../src/commons/platform_access/tls.rs | 48 ++++++++----------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/crates/stackable-operator/src/commons/platform_access/tls.rs b/crates/stackable-operator/src/commons/platform_access/tls.rs index 9660e6ea7..3f383561b 100644 --- a/crates/stackable-operator/src/commons/platform_access/tls.rs +++ b/crates/stackable-operator/src/commons/platform_access/tls.rs @@ -1,3 +1,4 @@ +use const_format::concatcp; use k8s_openapi::api::core::v1::{SecretVolumeSource, Volume, VolumeMount}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -15,6 +16,12 @@ use crate::{ v2::types::kubernetes::{SecretClassName, SecretName}, }; +/// Name of the volume holding the TLS client certificate. +pub const VOLUME_NAME: &str = "tls-client-cert"; + +/// Mount path for the TLS client certificate. +pub const MOUNT_PATH: &str = concatcp!(SECRET_BASE_PATH, "/", VOLUME_NAME); + /// Source of a TLS client certificate: a secret-operator SecretClass or a static Secret. #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -30,7 +37,7 @@ pub enum TlsClientCredential { impl TlsClientCredential { /// Adds the certificate volume to the Pod and mounts it into all given containers. /// - TlsClientCredential::Secret mounts the Secret - /// - TlsClientCredential::Secret adds a secret-operator volume + /// - TlsClientCredential::SecretClass adds a secret-operator volume pub fn add_volumes_and_mounts( &self, pod_builder: &mut PodBuilder, @@ -39,18 +46,17 @@ impl TlsClientCredential { let (volumes, mounts) = self.volumes_and_mounts(); pod_builder .add_volumes(volumes) - .expect("The volume name is derived from the credential and should not collide."); + .expect("Only a single platform access authentication variant can be chosen."); for container_builder in container_builders { container_builder .add_volume_mounts(mounts.clone()) - .expect("The mount path is derived from the credential and should not collide."); + .expect("Only a single platform access authentication variant can be chosen."); } } fn volumes_and_mounts(&self) -> (Vec, Vec) { - let volume_name = self.volume_name(); let volume = match self { - Self::SecretClass(secret_class) => VolumeBuilder::new(&volume_name) + Self::SecretClass(secret_class) => VolumeBuilder::new(VOLUME_NAME) .ephemeral( SecretOperatorVolumeSourceBuilder::new( secret_class, @@ -62,30 +68,16 @@ impl TlsClientCredential { .expect("the annotations are built from a valid SecretClass name"), ) .build(), - Self::Secret(secret) => Volume { - name: volume_name.clone(), - secret: Some(SecretVolumeSource { + Self::Secret(secret) => VolumeBuilder::new(VOLUME_NAME) + .secret(SecretVolumeSource { secret_name: Some(secret.to_string()), ..SecretVolumeSource::default() - }), - ..Volume::default() - }, + }) + .build(), }; - let mount = VolumeMountBuilder::new(&volume_name, self.mount_path()).build(); + let mount = VolumeMountBuilder::new(VOLUME_NAME, MOUNT_PATH).build(); (vec![volume], vec![mount]) } - - /// The directory containing the certificate as `tls.crt` and `tls.key` (PEM). - pub fn mount_path(&self) -> String { - format!("{SECRET_BASE_PATH}/{}", self.volume_name()) - } - - fn volume_name(&self) -> String { - match self { - Self::SecretClass(secret_class) => format!("{secret_class}-tls-cert"), - Self::Secret(secret) => format!("{secret}-tls-cert"), - } - } } #[cfg(test)] @@ -101,7 +93,7 @@ mod tests { let (volumes, mounts) = credential.volumes_and_mounts(); let volumes = serde_json::to_value(volumes).expect("serializable"); - assert_eq!(volumes[0]["name"], "tls-tls-cert"); + assert_eq!(volumes[0]["name"], "tls-client-cert"); assert_eq!( volumes[0]["ephemeral"]["volumeClaimTemplate"]["metadata"]["annotations"], json!({ @@ -113,7 +105,7 @@ mod tests { ); assert_eq!( serde_json::to_value(mounts).expect("serializable"), - json!([{"mountPath": "/stackable/secrets/tls-tls-cert", "name": "tls-tls-cert"}]) + json!([{"mountPath": "/stackable/secrets/tls-client-cert", "name": "tls-client-cert"}]) ); } @@ -125,11 +117,11 @@ mod tests { assert_eq!( serde_json::to_value(volumes).expect("serializable"), - json!([{"name": "my-cert-tls-cert", "secret": {"secretName": "my-cert"}}]) + json!([{"name": "tls-client-cert", "secret": {"secretName": "my-cert"}}]) ); assert_eq!( serde_json::to_value(mounts).expect("serializable"), - json!([{"mountPath": "/stackable/secrets/my-cert-tls-cert", "name": "my-cert-tls-cert"}]) + json!([{"mountPath": "/stackable/secrets/tls-client-cert", "name": "tls-client-cert"}]) ); } }