diff --git a/CHANGELOG.md b/CHANGELOG.md index 1061b4f07..8f8bcf343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Please see the relevant crate changelogs: - [config-utils](./crates/config-utils/CHANGELOG.md) - [containerdebug](./crates/containerdebug/CHANGELOG.md) - [k8s-version](./crates/k8s-version/CHANGELOG.md) +- [stackable-agent](./crates/stackable-agent/CHANGELOG.md) - [stackable-certs](./crates/stackable-certs/CHANGELOG.md) - [stackable-operator](./crates/stackable-operator/CHANGELOG.md) - [stackable-operator-derive](./crates/stackable-operator-derive/CHANGELOG.md) diff --git a/Cargo.lock b/Cargo.lock index bc5848908..a939acb2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3812,6 +3812,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stackable-agent" +version = "0.1.0" +dependencies = [ + "k8s-openapi", + "rstest", + "schemars", + "serde", + "serde_json", + "stackable-versioned", + "strum", +] + [[package]] name = "stackable-certs" version = "0.4.1" @@ -3864,6 +3877,7 @@ dependencies = [ "serde_json", "sha2", "snafu 0.9.2", + "stackable-agent", "stackable-certs", "stackable-operator-derive", "stackable-shared", diff --git a/crates/stackable-agent/CHANGELOG.md b/crates/stackable-agent/CHANGELOG.md new file mode 100644 index 000000000..7a48ea486 --- /dev/null +++ b/crates/stackable-agent/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.1.0] + +### Added + +- `DeletionPolicy`, whether an agent deletes a resource in the product. +- `ResourceOperation` with `reconciliationPaused`, which stops an agent from changing a resource + in the product. +- `Ready`/`Synced` conditions of resources managed by agents (`condition`), as `metav1.Condition` + with `True`/`False`/`Unknown` and `observedGeneration`. diff --git a/crates/stackable-agent/Cargo.toml b/crates/stackable-agent/Cargo.toml new file mode 100644 index 000000000..d4971cbeb --- /dev/null +++ b/crates/stackable-agent/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "stackable-agent" +description = "Building blocks for agents managing resources inside products of the Stackable Data Platform" +version = "0.1.0" +authors.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true + +[dependencies] +stackable-versioned = { path = "../stackable-versioned" } + +k8s-openapi.workspace = true +schemars.workspace = true +serde.workspace = true +strum.workspace = true + +[dev-dependencies] +rstest.workspace = true +serde_json.workspace = true + +[lints] +workspace = true diff --git a/crates/stackable-agent/src/condition.rs b/crates/stackable-agent/src/condition.rs new file mode 100644 index 000000000..112a6f463 --- /dev/null +++ b/crates/stackable-agent/src/condition.rs @@ -0,0 +1,191 @@ +//! 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; + +/// 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 status of a condition. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConditionStatus { + True, + False, + Unknown, +} + +/// 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: ConditionStatus, + + /// A CamelCase reason like `Available` 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 { + ConditionStatus::True => "True", + ConditionStatus::False => "False", + ConditionStatus::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: ConditionStatus, 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(ConditionStatus::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(ConditionStatus::True, "Created")], + &time(10), + Some(1), + ); + + let conditions = update_conditions( + ¤t, + vec![ready(ConditionStatus::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(ConditionStatus::True, "Created")], + &time(10), + Some(1), + ); + + let conditions = update_conditions( + ¤t, + vec![ready(ConditionStatus::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(ConditionStatus::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-agent/src/deletion.rs b/crates/stackable-agent/src/deletion.rs new file mode 100644 index 000000000..9b1335dab --- /dev/null +++ b/crates/stackable-agent/src/deletion.rs @@ -0,0 +1,44 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use stackable_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-agent/src/lib.rs b/crates/stackable-agent/src/lib.rs new file mode 100644 index 000000000..b68b2b232 --- /dev/null +++ b/crates/stackable-agent/src/lib.rs @@ -0,0 +1,5 @@ +//! Building blocks for agents managing resources inside products, like topics in Apache Kafka. + +pub mod condition; +pub mod deletion; +pub mod operation; diff --git a/crates/stackable-agent/src/operation.rs b/crates/stackable-agent/src/operation.rs new file mode 100644 index 000000000..a12e4bae1 --- /dev/null +++ b/crates/stackable-agent/src/operation.rs @@ -0,0 +1,38 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use stackable_versioned::versioned; + +#[versioned(version(name = "v1alpha1"))] +pub mod versioned { + /// Operations on this resource in the product, independent of the `clusterOperation` of the + /// product cluster. + #[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct ResourceOperation { + /// Stops the agent from changing the resource in the product. The status is still updated. + #[serde(default)] + pub reconciliation_paused: bool, + } +} + +#[cfg(test)] +mod tests { + use super::v1alpha1::ResourceOperation; + + #[test] + fn not_paused_by_default() { + let operation: ResourceOperation = + serde_json::from_str("{}").expect("the operation should be valid"); + + assert!(!operation.reconciliation_paused); + } + + #[test] + fn deserialize_paused() { + let operation: ResourceOperation = + serde_json::from_str(r#"{"reconciliationPaused": true}"#) + .expect("the operation should be valid"); + + assert!(operation.reconciliation_paused); + } +} diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index 4190bd1fa..ad78ca29a 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -4,6 +4,23 @@ 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. + - Labels and selectors for agents, with the component `-agent`. + - `v2::agent_utils::AgentResourceNames`, the names of the agent resources. +- Re-export `stackable-agent` as `agent` behind the `agent` feature ([#1290]). + +### Changed + +- BREAKING: The RBAC names of `v2::role_utils::ResourceNames` are now provided by the trait + `v2::rbac::RbacResourceNames`, which must be imported to call them. `v2::rbac::build_service_account` + and `v2::rbac::build_role_binding` accept any `RbacResourceNames` ([#1290]). + +[#1290]: https://github.com/stackabletech/operator-rs/pull/1290 + ## [0.121.0] - 2026-10-14 ### Changed diff --git a/crates/stackable-operator/Cargo.toml b/crates/stackable-operator/Cargo.toml index 0f242a7f4..f42ce1625 100644 --- a/crates/stackable-operator/Cargo.toml +++ b/crates/stackable-operator/Cargo.toml @@ -9,8 +9,9 @@ repository.workspace = true [features] default = ["crds"] -full = ["client-feature-gates", "crds", "certs", "test-support", "time", "webhook", "kube-ws", "kube-cel", "kube-http-proxy", "kube-socks5"] +full = ["agent", "client-feature-gates", "crds", "certs", "test-support", "time", "webhook", "kube-ws", "kube-cel", "kube-http-proxy", "kube-socks5"] +agent = ["dep:stackable-agent"] client-feature-gates = ["dep:winnow"] crds = ["dep:stackable-versioned"] certs = ["dep:stackable-certs"] @@ -23,6 +24,7 @@ kube-http-proxy = ["kube/http-proxy"] kube-socks5 = ["kube/socks5"] [dependencies] +stackable-agent = { path = "../stackable-agent", optional = true } stackable-certs = { path = "../stackable-certs", optional = true } stackable-operator-derive = { path = "../stackable-operator-derive" } stackable-shared = { path = "../stackable-shared", features = ["jiff"] } 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/mod.rs b/crates/stackable-operator/src/crd/management_access/mod.rs new file mode 100644 index 000000000..24b92efae --- /dev/null +++ b/crates/stackable-operator/src/crd/management_access/mod.rs @@ -0,0 +1,8 @@ +//! CRD fragments for resources managed by product agents, and for the credentials the agents use. + +pub mod anonymous; +pub mod tls; + +pub mod v1alpha1 { + pub use super::{anonymous::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/lib.rs b/crates/stackable-operator/src/lib.rs index e52cd0382..e611d8913 100644 --- a/crates/stackable-operator/src/lib.rs +++ b/crates/stackable-operator/src/lib.rs @@ -42,6 +42,8 @@ pub use schemars; // Internal re-exports // TODO (@Techassi): Ideally we would want webhook and certs exported here as // well, but that would require some restructuring of crates. +#[cfg(feature = "agent")] +pub use stackable_agent as agent; #[cfg(feature = "certs")] pub use stackable_certs as certs; pub use stackable_shared as shared; diff --git a/crates/stackable-operator/src/v2/agent_utils.rs b/crates/stackable-operator/src/v2/agent_utils.rs new file mode 100644 index 000000000..fd376b26d --- /dev/null +++ b/crates/stackable-operator/src/v2/agent_utils.rs @@ -0,0 +1,104 @@ +use std::str::FromStr; + +use super::{ + rbac::RbacResourceNames, + types::{ + kubernetes::{ClusterRoleName, DeploymentName, RoleBindingName, ServiceAccountName}, + operator::{ClusterName, ProductName}, + }, +}; + +const SUFFIX: &str = "-agent"; + +/// Type-safe names for the resources of a product agent. +pub struct AgentResourceNames { + pub cluster_name: ClusterName, + pub product_name: ProductName, +} + +impl AgentResourceNames { + pub fn deployment_name(&self) -> DeploymentName { + // compile-time checks + const _: () = assert!( + ClusterName::MAX_LENGTH + SUFFIX.len() <= DeploymentName::MAX_LENGTH, + "The string `-agent` must not exceed the limit of Deployment names." + ); + let _ = ClusterName::IS_RFC_1035_LABEL_NAME; + + DeploymentName::from_str(&self.name()).expect("should be a valid Deployment name") + } + + fn name(&self) -> String { + let cluster_name = &self.cluster_name; + format!("{cluster_name}{SUFFIX}") + } +} + +impl RbacResourceNames for AgentResourceNames { + fn service_account_name(&self) -> ServiceAccountName { + // compile-time checks + const _: () = assert!( + ClusterName::MAX_LENGTH + SUFFIX.len() <= ServiceAccountName::MAX_LENGTH, + "The string `-agent` must not exceed the limit of ServiceAccount names." + ); + let _ = ClusterName::IS_RFC_1123_SUBDOMAIN_NAME; + + ServiceAccountName::from_str(&self.name()).expect("should be a valid ServiceAccount name") + } + + fn role_binding_name(&self) -> RoleBindingName { + // compile-time checks + const _: () = assert!( + ClusterName::MAX_LENGTH + SUFFIX.len() <= RoleBindingName::MAX_LENGTH, + "The string `-agent` must not exceed the limit of RoleBinding names." + ); + let _ = ClusterName::IS_RFC_1123_SUBDOMAIN_NAME; + + RoleBindingName::from_str(&self.name()).expect("should be a valid RoleBinding name") + } + + fn cluster_role_name(&self) -> ClusterRoleName { + const CLUSTER_ROLE_SUFFIX: &str = "-agent-clusterrole"; + + // compile-time checks + const _: () = assert!( + ProductName::MAX_LENGTH + CLUSTER_ROLE_SUFFIX.len() <= ClusterRoleName::MAX_LENGTH, + "The string `-agent-clusterrole` must not exceed the limit of cluster role names." + ); + let _ = ProductName::IS_RFC_1123_SUBDOMAIN_NAME; + + let product_name = &self.product_name; + ClusterRoleName::from_str(&format!("{product_name}{CLUSTER_ROLE_SUFFIX}")) + .expect("should be a valid cluster role name") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_agent_resource_names() { + let resource_names = AgentResourceNames { + cluster_name: ClusterName::from_str_unsafe("my-cluster"), + product_name: ProductName::from_str_unsafe("my-product"), + }; + + assert_eq!( + DeploymentName::from_str_unsafe("my-cluster-agent"), + resource_names.deployment_name() + ); + assert_eq!( + ServiceAccountName::from_str_unsafe("my-cluster-agent"), + resource_names.service_account_name() + ); + assert_eq!( + RoleBindingName::from_str_unsafe("my-cluster-agent"), + resource_names.role_binding_name() + ); + assert_eq!( + ClusterRoleName::from_str_unsafe("my-product-agent-clusterrole"), + resource_names.cluster_role_name() + ); + } +} diff --git a/crates/stackable-operator/src/v2/kvp/label.rs b/crates/stackable-operator/src/v2/kvp/label.rs index a0d806557..920577591 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,45 @@ 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 selector for the agent of the given cluster. +/// +/// 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 selector for the agents of all clusters of the given product, e.g. to watch them. +/// +/// The returned labels are a subset of the recommended labels for agent resources. +pub fn all_agents_selector(product_name: &ProductName) -> Labels { + Labels::from_iter([ + 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 +201,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 +269,68 @@ 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 all_agents_selector_is_subset_of_agent_selector() { + let product_name = ProductName::from_str_unsafe("my-product"); + + let agent_selector = + agent_selector(&ClusterName::from_str_unsafe("cluster-name"), &product_name); + let all_agents_selector = all_agents_selector(&product_name); + + assert!( + all_agents_selector + .iter() + .all(|selector| agent_selector.contains(selector)) + ); + assert!(!all_agents_selector.contains_key("app.kubernetes.io/instance")); + } + #[test] fn recommended_labels_for_role_resources_produces_expected_labels() { let actual_labels = recommended_labels_for_role_resources( diff --git a/crates/stackable-operator/src/v2/mod.rs b/crates/stackable-operator/src/v2/mod.rs index efc90c1a3..b06ba7838 100644 --- a/crates/stackable-operator/src/v2/mod.rs +++ b/crates/stackable-operator/src/v2/mod.rs @@ -1,5 +1,6 @@ use crate::v2::types::kubernetes::Uid; +pub mod agent_utils; pub mod builder; pub mod cluster_resources; pub mod config_file_writer; diff --git a/crates/stackable-operator/src/v2/rbac.rs b/crates/stackable-operator/src/v2/rbac.rs index bab5a85d9..c41cab036 100644 --- a/crates/stackable-operator/src/v2/rbac.rs +++ b/crates/stackable-operator/src/v2/rbac.rs @@ -10,18 +10,32 @@ use crate::{ }, kube::{Resource, ResourceExt}, kvp::Labels, - v2::{HasName, HasUid, builder::meta::ownerreference_from_resource, role_utils::ResourceNames}, + v2::{ + HasName, HasUid, + builder::meta::ownerreference_from_resource, + types::kubernetes::{ClusterRoleName, RoleBindingName, ServiceAccountName}, + }, }; -/// Builds the [`ServiceAccount`] for the product workloads, named -/// `-serviceaccount` after the given [`ResourceNames`]. +/// Names of the RBAC resources built by [`build_service_account`] and [`build_role_binding`]. +/// +/// Implemented by [`crate::v2::role_utils::ResourceNames`] for the product workloads and by +/// [`crate::v2::agent_utils::AgentResourceNames`] for a product agent. +pub trait RbacResourceNames { + fn service_account_name(&self) -> ServiceAccountName; + fn role_binding_name(&self) -> RoleBindingName; + fn cluster_role_name(&self) -> ClusterRoleName; +} + +/// Builds the [`ServiceAccount`] named after the given [`RbacResourceNames`]. /// /// Together with [`build_role_binding`] this is the infallible variant of -/// [`crate::commons::rbac::build_rbac_resources`]; the output is identical as long as +/// [`crate::commons::rbac::build_rbac_resources`]; for +/// [`crate::v2::role_utils::ResourceNames`], the output is identical as long as /// `resource_names.cluster_name` matches the metadata name of `owner`. pub fn build_service_account( owner: &(impl Resource + HasName + HasUid), - resource_names: &ResourceNames, + resource_names: &impl RbacResourceNames, labels: Labels, ) -> ServiceAccount { ServiceAccount { @@ -34,17 +48,17 @@ pub fn build_service_account( } } -/// Builds the [`RoleBinding`] for the product workloads, named `-rolebinding` -/// after the given [`ResourceNames`]. It binds the [`ServiceAccount`] from -/// [`build_service_account`] to the operator-deployed ClusterRole -/// `-clusterrole`, which must already exist. +/// Builds the [`RoleBinding`] named after the given [`RbacResourceNames`]. It binds the +/// [`ServiceAccount`] from [`build_service_account`] to the operator-deployed ClusterRole, which +/// must already exist. /// /// Together with [`build_service_account`] this is the infallible variant of -/// [`crate::commons::rbac::build_rbac_resources`]; the output is identical as long as +/// [`crate::commons::rbac::build_rbac_resources`]; for +/// [`crate::v2::role_utils::ResourceNames`], the output is identical as long as /// `resource_names.cluster_name` matches the metadata name of `owner`. pub fn build_role_binding( owner: &(impl Resource + HasName + HasUid), - resource_names: &ResourceNames, + resource_names: &impl RbacResourceNames, labels: Labels, ) -> RoleBinding { RoleBinding { diff --git a/crates/stackable-operator/src/v2/role_utils.rs b/crates/stackable-operator/src/v2/role_utils.rs index fd55eb765..92ef2b08a 100644 --- a/crates/stackable-operator/src/v2/role_utils.rs +++ b/crates/stackable-operator/src/v2/role_utils.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use super::{ builder::pod::container::EnvVarSet, jvm_argument_overrides::JvmArgumentOverrides, + rbac::RbacResourceNames, types::{ kubernetes::{ClusterRoleName, RoleBindingName, ServiceAccountName}, operator::{ClusterName, ProductName}, @@ -331,8 +332,8 @@ pub struct ResourceNames { pub product_name: ProductName, } -impl ResourceNames { - pub fn service_account_name(&self) -> ServiceAccountName { +impl RbacResourceNames for ResourceNames { + fn service_account_name(&self) -> ServiceAccountName { const SUFFIX: &str = "-serviceaccount"; // compile-time checks @@ -346,7 +347,7 @@ impl ResourceNames { .expect("should be a valid ServiceAccount name") } - pub fn role_binding_name(&self) -> RoleBindingName { + fn role_binding_name(&self) -> RoleBindingName { const SUFFIX: &str = "-rolebinding"; // compile-time checks @@ -360,7 +361,7 @@ impl ResourceNames { .expect("should be a valid RoleBinding name") } - pub fn cluster_role_name(&self) -> ClusterRoleName { + fn cluster_role_name(&self) -> ClusterRoleName { const SUFFIX: &str = "-clusterrole"; // compile-time checks