diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index 4190bd1fa..2a1e261d2 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -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 `-agent`. + +[#1290]: https://github.com/stackabletech/operator-rs/pull/1290 + ## [0.121.0] - 2026-10-14 ### Changed diff --git a/crates/stackable-operator/src/crd/management_access/anonymous/mod.rs b/crates/stackable-operator/src/crd/management_access/anonymous/mod.rs new file mode 100644 index 000000000..e43a1b6f3 --- /dev/null +++ b/crates/stackable-operator/src/crd/management_access/anonymous/mod.rs @@ -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"]); + } +} diff --git a/crates/stackable-operator/src/crd/management_access/deletion.rs b/crates/stackable-operator/src/crd/management_access/deletion.rs new file mode 100644 index 000000000..c2315b65a --- /dev/null +++ b/crates/stackable-operator/src/crd/management_access/deletion.rs @@ -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::(r#""Orphan""#).is_err()); + } +} diff --git a/crates/stackable-operator/src/crd/management_access/mod.rs b/crates/stackable-operator/src/crd/management_access/mod.rs new file mode 100644 index 000000000..14b9343a3 --- /dev/null +++ b/crates/stackable-operator/src/crd/management_access/mod.rs @@ -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::*}; +} diff --git a/crates/stackable-operator/src/crd/management_access/tls/mod.rs b/crates/stackable-operator/src/crd/management_access/tls/mod.rs new file mode 100644 index 000000000..9f050fc5f --- /dev/null +++ b/crates/stackable-operator/src/crd/management_access/tls/mod.rs @@ -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), + } +} diff --git a/crates/stackable-operator/src/crd/management_access/tls/v1alpha1_impl.rs b/crates/stackable-operator/src/crd/management_access/tls/v1alpha1_impl.rs new file mode 100644 index 000000000..3c995335c --- /dev/null +++ b/crates/stackable-operator/src/crd/management_access/tls/v1alpha1_impl.rs @@ -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, Vec) { + 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"}]) + ); + } +} diff --git a/crates/stackable-operator/src/crd/mod.rs b/crates/stackable-operator/src/crd/mod.rs index 48681601c..5110b76bd 100644 --- a/crates/stackable-operator/src/crd/mod.rs +++ b/crates/stackable-operator/src/crd/mod.rs @@ -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; diff --git a/crates/stackable-operator/src/status/condition/managed_resource.rs b/crates/stackable-operator/src/status/condition/managed_resource.rs new file mode 100644 index 000000000..607e3507a --- /dev/null +++ b/crates/stackable-operator/src/status/condition/managed_resource.rs @@ -0,0 +1,185 @@ +//! Conditions of resources an agent manages inside a product, as `metav1.Condition`. + +use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, Time}; +use strum::Display; + +use crate::status::condition::ClusterConditionStatus; + +/// Condition types of resources an agent manages inside a product. +#[derive(Clone, Copy, Debug, Display, Eq, PartialEq)] +pub enum ManagedConditionType { + /// The resource exists in the product and is usable. + Ready, + + /// The last reconciliation applied the spec to the resource in the product. + Synced, +} + +/// The desired state of one condition, without the bookkeeping fields. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ManagedCondition { + pub type_: ManagedConditionType, + + /// `Unknown` if the agent could not determine the state, e.g. because the product is not + /// reachable. + pub status: ClusterConditionStatus, + + /// A CamelCase reason like `Created` or `UnsupportedChange`. + pub reason: String, + + pub message: String, +} + +/// Merges the given conditions into the current ones. +/// +/// `lastTransitionTime` only changes if the status of a condition changes. Conditions of other +/// types are kept. `observed_generation` is the `metadata.generation` of the object the conditions +/// were determined for. +pub fn update_conditions( + current: &[Condition], + updates: Vec, + now: &Time, + observed_generation: Option, +) -> Vec { + let mut conditions = current.to_vec(); + + for update in updates { + let type_ = update.type_.to_string(); + let status = match update.status { + ClusterConditionStatus::True => "True", + ClusterConditionStatus::False => "False", + ClusterConditionStatus::Unknown => "Unknown", + } + .to_owned(); + let existing = conditions + .iter() + .position(|condition| condition.type_ == type_); + + let last_transition_time = match existing { + Some(index) if conditions[index].status == status => { + conditions[index].last_transition_time.clone() + } + _ => now.clone(), + }; + + let condition = Condition { + last_transition_time, + message: update.message, + observed_generation, + reason: update.reason, + status, + type_, + }; + + match existing { + Some(index) => conditions[index] = condition, + None => conditions.push(condition), + } + } + + conditions +} + +#[cfg(test)] +mod tests { + use k8s_openapi::jiff::Timestamp; + + use super::*; + + fn time(seconds: i64) -> Time { + Time(Timestamp::from_second(seconds).expect("the timestamp should be valid")) + } + + fn ready(status: ClusterConditionStatus, reason: &str) -> ManagedCondition { + ManagedCondition { + type_: ManagedConditionType::Ready, + status, + reason: reason.to_owned(), + message: String::new(), + } + } + + #[test] + fn add_missing_condition() { + let conditions = update_conditions( + &[], + vec![ready(ClusterConditionStatus::True, "Created")], + &time(10), + Some(3), + ); + + assert_eq!(1, conditions.len()); + assert_eq!("Ready", conditions[0].type_); + assert_eq!("True", conditions[0].status); + assert_eq!("Created", conditions[0].reason); + assert_eq!(time(10), conditions[0].last_transition_time); + assert_eq!(Some(3), conditions[0].observed_generation); + } + + #[test] + fn keep_transition_time_if_status_is_unchanged() { + let current = update_conditions( + &[], + vec![ready(ClusterConditionStatus::True, "Created")], + &time(10), + Some(1), + ); + + let conditions = update_conditions( + ¤t, + vec![ready(ClusterConditionStatus::True, "Updated")], + &time(20), + Some(2), + ); + + assert_eq!("Updated", conditions[0].reason); + assert_eq!(time(10), conditions[0].last_transition_time); + assert_eq!(Some(2), conditions[0].observed_generation); + } + + #[test] + fn update_transition_time_if_status_changes() { + let current = update_conditions( + &[], + vec![ready(ClusterConditionStatus::True, "Created")], + &time(10), + Some(1), + ); + + let conditions = update_conditions( + ¤t, + vec![ready(ClusterConditionStatus::Unknown, "ConnectionFailed")], + &time(20), + Some(1), + ); + + assert_eq!("Unknown", conditions[0].status); + assert_eq!(time(20), conditions[0].last_transition_time); + } + + #[test] + fn keep_conditions_of_other_types() { + let other = Condition { + last_transition_time: time(5), + message: String::new(), + observed_generation: None, + reason: "Other".to_owned(), + status: "True".to_owned(), + type_: "Other".to_owned(), + }; + + let conditions = update_conditions( + std::slice::from_ref(&other), + vec![ready(ClusterConditionStatus::True, "Created")], + &time(10), + Some(1), + ); + + let types: Vec<_> = conditions + .iter() + .map(|condition| condition.type_.as_str()) + .collect(); + assert_eq!(vec!["Other", "Ready"], types); + assert_eq!(None, conditions[0].observed_generation); + } +} diff --git a/crates/stackable-operator/src/status/condition/mod.rs b/crates/stackable-operator/src/status/condition/mod.rs index ae06af242..be40ec627 100644 --- a/crates/stackable-operator/src/status/condition/mod.rs +++ b/crates/stackable-operator/src/status/condition/mod.rs @@ -1,5 +1,6 @@ pub mod daemonset; pub mod deployment; +pub mod managed_resource; pub mod operations; pub mod statefulset; diff --git a/crates/stackable-operator/src/v2/kvp/label.rs b/crates/stackable-operator/src/v2/kvp/label.rs index a0d806557..93cc9ba0d 100644 --- a/crates/stackable-operator/src/v2/kvp/label.rs +++ b/crates/stackable-operator/src/v2/kvp/label.rs @@ -4,6 +4,7 @@ use crate::{ kvp::{Label, Labels, consts::K8S_APP_MANAGED_BY_KEY}, v2::{ NameIsValidLabelValue, + macros::attributed_string_type::MAX_LABEL_VALUE_LENGTH, types::operator::{ ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, RoleGroupName, RoleName, @@ -11,6 +12,12 @@ use crate::{ }, }; +const AGENT_COMPONENT_SUFFIX: &str = "-agent"; +const _: () = assert!( + ProductName::MAX_LENGTH + AGENT_COMPONENT_SUFFIX.len() <= MAX_LABEL_VALUE_LENGTH, + "The string `-agent` must not exceed the limit of label values." +); + /// Creates the recommended labels for cluster resources, like ServiceAccounts. pub fn recommended_labels_for_cluster_resources( cluster_name: &ClusterName, @@ -28,6 +35,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(product_name), + 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(product_name), + ]) +} + /// Creates the recommended labels for role resources, like discovery ConfigMaps. pub fn recommended_labels_for_role_resources( cluster_name: &ClusterName, @@ -155,6 +191,15 @@ 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(product_name: &ProductName) -> Label { + Label::component(&format!( + "{}{AGENT_COMPONENT_SUFFIX}", + product_name.to_label_value() + )) + .expect("the product name is a valid label value and the suffix keeps it within the limit") +} + /// 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 +259,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", "my-product-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(