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
11 changes: 11 additions & 0 deletions crates/stackable-operator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ 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 management access used for adding volumes and mounts.
- `Anonymous`, for management access to products that don't authenticate the agent.
- `DeletionPolicy`, whether an agent deletes a resource in the product.
- `Ready`/`Synced` conditions of resources managed by agents (`status::condition::managed_resource`).
- Labels and selectors for agents, with the component `<product-name>-agent`.

[#1290]: https://github.com/stackabletech/operator-rs/pull/1290

## [0.121.0] - 2026-10-14

### Changed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::versioned::versioned;

#[versioned(version(name = "v1alpha1"))]
pub mod versioned {
/// No credential: the product does not require authentication.
#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
pub struct Anonymous {}
}

#[cfg(test)]
mod tests {
use super::v1alpha1::Anonymous;

#[test]
fn deserialize_empty_object() {
let anonymous: Anonymous =
serde_json::from_str("{}").expect("an empty object should be valid");

assert_eq!(Anonymous {}, anonymous);
}

#[test]
fn schema_is_an_object() {
let schema = serde_json::to_value(schemars::schema_for!(Anonymous))
.expect("the schema should be serializable");

assert_eq!("object", schema["type"]);
}
}
45 changes: 45 additions & 0 deletions crates/stackable-operator/src/crd/management_access/deletion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::versioned::versioned;

#[versioned(version(name = "v1alpha1"))]
pub mod versioned {
/// What happens to a resource in the product when the object managing it is deleted.
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
pub enum DeletionPolicy {
/// Keep the resource in the product.
#[default]
Retain,

/// Delete the resource in the product.
Delete,
}
}

#[cfg(test)]
mod tests {
use rstest::rstest;

use super::v1alpha1::DeletionPolicy;

#[rstest]
#[case::retain(r#""Retain""#, DeletionPolicy::Retain)]
#[case::delete(r#""Delete""#, DeletionPolicy::Delete)]
fn deserialize_policy(#[case] input: &str, #[case] expected: DeletionPolicy) {
let policy: DeletionPolicy =
serde_json::from_str(input).expect("the policy should be valid");

assert_eq!(expected, policy);
}

#[test]
fn retain_by_default() {
assert_eq!(DeletionPolicy::Retain, DeletionPolicy::default());
}

#[test]
fn reject_unknown_policy() {
assert!(serde_json::from_str::<DeletionPolicy>(r#""Orphan""#).is_err());
}
}
9 changes: 9 additions & 0 deletions crates/stackable-operator/src/crd/management_access/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//! CRD fragments for resources managed by product agents, and for the credentials the agents use.

pub mod anonymous;
pub mod deletion;
pub mod tls;

pub mod v1alpha1 {
pub use super::{anonymous::v1alpha1::*, deletion::v1alpha1::*, tls::v1alpha1::*};
}
36 changes: 36 additions & 0 deletions crates/stackable-operator/src/crd/management_access/tls/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use const_format::concatcp;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::{
constants::secret::SECRET_BASE_PATH,
v2::types::kubernetes::{SecretClassName, SecretName},
versioned::versioned,
};

mod v1alpha1_impl;

/// Name of the volume holding the TLS client certificate.
pub const VOLUME_NAME: &str = "tls-management-access";

/// Mount path for the TLS client certificate.
pub const MOUNT_PATH: &str = concatcp!(SECRET_BASE_PATH, "/", VOLUME_NAME);

#[versioned(version(name = "v1alpha1"))]
pub mod versioned {
pub mod v1alpha1 {
pub use v1alpha1_impl::Error;
}

/// 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.
SecretClassName(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`.
SecretName(SecretName),
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
use k8s_openapi::api::core::v1::{Volume, VolumeMount};
use snafu::{ResultExt as _, Snafu};

use crate::{
builder::{
self,
pod::{
PodBuilder,
container::ContainerBuilder,
volume::{
SecretFormat, SecretOperatorVolumeSourceBuilder, VolumeBuilder, VolumeMountBuilder,
},
},
},
commons::secret_class::SecretClassVolumeProvisionParts,
crd::management_access::tls::{MOUNT_PATH, VOLUME_NAME, v1alpha1::TlsClientCredential},
};

#[derive(Debug, Snafu)]
pub enum Error {
#[snafu(display("failed to add the TLS client certificate volume"))]
AddVolumes { source: builder::pod::Error },

#[snafu(display("failed to add the TLS client certificate volumeMount"))]
AddVolumeMounts {
source: builder::pod::container::Error,
},
}

impl TlsClientCredential {
/// Adds the certificate volume to the Pod and mounts it into all given containers.
/// - TlsClientCredential::SecretName mounts the Secret
/// - TlsClientCredential::SecretClassName adds a secret-operator volume
pub fn add_volumes_and_mounts(
&self,
pod_builder: &mut PodBuilder,
container_builders: Vec<&mut ContainerBuilder>,
) -> Result<(), Error> {
let (volumes, mounts) = self.volumes_and_mounts();
pod_builder.add_volumes(volumes).context(AddVolumesSnafu)?;
for container_builder in container_builders {
container_builder
.add_volume_mounts(mounts.clone())
.context(AddVolumeMountsSnafu)?;
}

Ok(())
}

fn volumes_and_mounts(&self) -> (Vec<Volume>, Vec<VolumeMount>) {
let volume = match self {
Self::SecretClassName(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 and constant values"),
)
.build(),
Self::SecretName(secret) => VolumeBuilder::new(VOLUME_NAME)
.with_secret(secret.to_string(), false)
.build(),
};
let mount = VolumeMountBuilder::new(VOLUME_NAME, MOUNT_PATH).build();
(vec![volume], vec![mount])
}
}

#[cfg(test)]
mod tests {
use serde_json::json;

use super::*;
use crate::v2::types::kubernetes::{SecretClassName, SecretName};

#[test]
fn secret_class_credential_is_provisioned_by_secret_operator() {
let credential =
TlsClientCredential::SecretClassName(SecretClassName::from_str_unsafe("tls"));

let (volumes, mounts) = credential.volumes_and_mounts();
let volumes = serde_json::to_value(volumes).expect("the volumes should be serializable");

assert_eq!(volumes[0]["name"], "tls-management-access");
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("the volume mounts should be serializable"),
json!([{"mountPath": "/stackable/secrets/tls-management-access", "name": "tls-management-access"}])
);
}

#[test]
fn static_secret_credential_is_mounted_directly() {
let credential = TlsClientCredential::SecretName(SecretName::from_str_unsafe("my-cert"));

let (volumes, mounts) = credential.volumes_and_mounts();

assert_eq!(
serde_json::to_value(volumes).expect("the volumes should be serializable"),
json!([{"name": "tls-management-access", "secret": {"optional": false, "secretName": "my-cert"}}])
);
assert_eq!(
serde_json::to_value(mounts).expect("the volume mounts should be serializable"),
json!([{"mountPath": "/stackable/secrets/tls-management-access", "name": "tls-management-access"}])
);
}
}
1 change: 1 addition & 0 deletions crates/stackable-operator/src/crd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
pub mod authentication;
pub mod git_sync;
pub mod listener;
pub mod management_access;
pub mod openlineage;
pub mod s3;
pub mod scaler;
Expand Down
Loading
Loading