Skip to content
Draft
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
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions crates/stackable-operator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/stackable-operator/src/commons/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions crates/stackable-operator/src/commons/platform_access/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
//! Credentials a product cluster grants the platform-access agent, and how to mount them.

pub mod tls;
127 changes: 127 additions & 0 deletions crates/stackable-operator/src/commons/platform_access/tls.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
use const_format::concatcp;
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},
};

/// 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")]
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::SecretClass 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("Only a single platform access authentication variant can be chosen.");
for container_builder in container_builders {
container_builder
.add_volume_mounts(mounts.clone())
.expect("Only a single platform access authentication variant can be chosen.");
}
}

fn volumes_and_mounts(&self) -> (Vec<Volume>, Vec<VolumeMount>) {
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) => VolumeBuilder::new(VOLUME_NAME)
.secret(SecretVolumeSource {
secret_name: Some(secret.to_string()),
..SecretVolumeSource::default()
})
.build(),
};
let mount = VolumeMountBuilder::new(VOLUME_NAME, MOUNT_PATH).build();
(vec![volume], vec![mount])
}
}

#[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-client-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-client-cert", "name": "tls-client-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": "tls-client-cert", "secret": {"secretName": "my-cert"}}])
);
assert_eq!(
serde_json::to_value(mounts).expect("serializable"),
json!([{"mountPath": "/stackable/secrets/tls-client-cert", "name": "tls-client-cert"}])
);
}
}
80 changes: 80 additions & 0 deletions crates/stackable-operator/src/v2/kvp/label.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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(
Expand Down
Loading