From 9e03eb34c1e4aa8ecbc4836bbbc258bd59ed94c5 Mon Sep 17 00:00:00 2001 From: GatewayJ <18332154+GatewayJ@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:08:33 +0800 Subject: [PATCH 1/3] feat(operator): support dedicated RPC secrets --- deploy/rustfs-operator/README.md | 19 +++ deploy/rustfs-operator/crds/tenant-crd.yaml | 20 +++ deploy/rustfs-operator/crds/tenant.yaml | 20 +++ src/types/v1alpha1.rs | 14 +++ src/types/v1alpha1/tenant.rs | 20 +++ src/types/v1alpha1/tenant/workloads.rs | 127 ++++++++++++++++++++ 6 files changed, 220 insertions(+) diff --git a/deploy/rustfs-operator/README.md b/deploy/rustfs-operator/README.md index 74f4e9f..a1eb1e2 100755 --- a/deploy/rustfs-operator/README.md +++ b/deploy/rustfs-operator/README.md @@ -89,6 +89,25 @@ Operator STS does not present a client certificate when calling the Tenant. Tena When `operator.serviceMonitor.enabled=true`, the chart creates scrape targets for both the operator observability endpoint and the Console API `/metrics` endpoint. +### Tenant RPC Authentication + +Use `spec.rpcSecret` to keep RustFS internode RPC authentication independent from +the administrator credentials in `spec.credsSecret`: + +```yaml +spec: + credsSecret: + name: rustfs-admin-creds + rpcSecret: + name: rustfs-rpc-auth + key: rpc-secret +``` + +The operator maps the selected Secret key to `RUSTFS_RPC_SECRET` in every RustFS +Pod. Keep this value stable while rotating administrator credentials. If +`spec.rpcSecret` is omitted, the operator does not set `RUSTFS_RPC_SECRET` and +RustFS resolves the RPC secret from its own credential configuration. + ### Tenant Provisioning Tenants can declare RustFS canned policies, regular users, and buckets directly in `spec.policies`, `spec.users`, and `spec.buckets`. Provisioning starts only after the Tenant workload is ready, uses `spec.credsSecret` as the RustFS admin credential source, and reports progress under `status.provisioning`. diff --git a/deploy/rustfs-operator/crds/tenant-crd.yaml b/deploy/rustfs-operator/crds/tenant-crd.yaml index fceeaa0..436ea80 100644 --- a/deploy/rustfs-operator/crds/tenant-crd.yaml +++ b/deploy/rustfs-operator/crds/tenant-crd.yaml @@ -1410,6 +1410,26 @@ spec: priorityClassName: nullable: true type: string + rpcSecret: + description: |- + Optional Secret key selector for RustFS internode RPC authentication. + + When configured, the operator maps this key to `RUSTFS_RPC_SECRET` for every + RustFS Pod. Keep it stable while rotating `credsSecret`. When omitted, the + operator does not set `RUSTFS_RPC_SECRET`; RustFS resolves it from its own + credential configuration. + nullable: true + properties: + key: + minLength: 1 + type: string + name: + minLength: 1 + type: string + required: + - key + - name + type: object scheduler: nullable: true type: string diff --git a/deploy/rustfs-operator/crds/tenant.yaml b/deploy/rustfs-operator/crds/tenant.yaml index fceeaa0..436ea80 100755 --- a/deploy/rustfs-operator/crds/tenant.yaml +++ b/deploy/rustfs-operator/crds/tenant.yaml @@ -1410,6 +1410,26 @@ spec: priorityClassName: nullable: true type: string + rpcSecret: + description: |- + Optional Secret key selector for RustFS internode RPC authentication. + + When configured, the operator maps this key to `RUSTFS_RPC_SECRET` for every + RustFS Pod. Keep it stable while rotating `credsSecret`. When omitted, the + operator does not set `RUSTFS_RPC_SECRET`; RustFS resolves it from its own + credential configuration. + nullable: true + properties: + key: + minLength: 1 + type: string + name: + minLength: 1 + type: string + required: + - key + - name + type: object scheduler: nullable: true type: string diff --git a/src/types/v1alpha1.rs b/src/types/v1alpha1.rs index 5ebf2c8..36d11f6 100755 --- a/src/types/v1alpha1.rs +++ b/src/types/v1alpha1.rs @@ -154,6 +154,20 @@ mod tenant_provisioning_crd_tests { let spec = &schema["properties"]["spec"]; let status = &schema["properties"]["status"]; + assert_eq!(spec["properties"]["rpcSecret"]["type"], json!("object")); + assert_eq!( + spec["properties"]["rpcSecret"]["properties"]["name"]["minLength"], + json!(1) + ); + assert_eq!( + spec["properties"]["rpcSecret"]["properties"]["key"]["minLength"], + json!(1) + ); + assert_eq!( + spec["properties"]["rpcSecret"]["required"], + json!(["key", "name"]) + ); + assert_eq!(spec["properties"]["policies"]["type"], json!("array")); assert_eq!(spec["properties"]["users"]["type"], json!("array")); assert_eq!(spec["properties"]["buckets"]["type"], json!("array")); diff --git a/src/types/v1alpha1/tenant.rs b/src/types/v1alpha1/tenant.rs index dd6f1e3..4fea7b6 100755 --- a/src/types/v1alpha1/tenant.rs +++ b/src/types/v1alpha1/tenant.rs @@ -39,6 +39,17 @@ pub(crate) const MAX_TENANT_POLICIES: u32 = 256; pub(crate) const MAX_TENANT_USERS: u32 = 256; pub(crate) const MAX_TENANT_BUCKETS: u32 = 1024; +/// Reference to one key in a namespaced Kubernetes Secret. +#[derive(Deserialize, Serialize, Clone, Debug, KubeSchema, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RpcSecretRef { + #[schemars(length(min = 1))] + pub name: String, + + #[schemars(length(min = 1))] + pub key: String, +} + #[derive(CustomResource, Deserialize, Serialize, Clone, Debug, KubeSchema, Default)] #[kube( group = "rustfs.com", @@ -169,6 +180,15 @@ pub struct TenantSpec { #[serde(default, skip_serializing_if = "Option::is_none")] pub creds_secret: Option, + /// Optional Secret key selector for RustFS internode RPC authentication. + /// + /// When configured, the operator maps this key to `RUSTFS_RPC_SECRET` for every + /// RustFS Pod. Keep it stable while rotating `credsSecret`. When omitted, the + /// operator does not set `RUSTFS_RPC_SECRET`; RustFS resolves it from its own + /// credential configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rpc_secret: Option, + /// Canned policies that should be applied to the RustFS tenant. #[schemars( length(max = MAX_TENANT_POLICIES), diff --git a/src/types/v1alpha1/tenant/workloads.rs b/src/types/v1alpha1/tenant/workloads.rs index 39f2ee8..640b69d 100755 --- a/src/types/v1alpha1/tenant/workloads.rs +++ b/src/types/v1alpha1/tenant/workloads.rs @@ -30,6 +30,7 @@ const LOCAL_KMS_KEY_DIR_ENV: &str = "RUSTFS_KMS_KEY_DIR"; const LOCAL_KMS_LOCAL_KEY_DIR_ENV: &str = "RUSTFS_KMS_LOCAL_KEY_DIR"; const LOCAL_KMS_MASTER_KEY_ENV: &str = "RUSTFS_KMS_LOCAL_MASTER_KEY"; const KMS_ALLOW_INSECURE_DEV_DEFAULTS_ENV: &str = "RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS"; +const RPC_SECRET_ENV: &str = "RUSTFS_RPC_SECRET"; const VOLUME_CLAIM_TEMPLATE_PREFIX: &str = "vol"; const DEFAULT_RUN_AS_USER: i64 = 10001; const DEFAULT_RUN_AS_GROUP: i64 = 10001; @@ -522,6 +523,24 @@ impl Tenant { }); } + // Keep internode RPC authentication independent from admin credentials when + // the Tenant explicitly selects a dedicated Secret key. If omitted, RustFS + // retains ownership of RPC secret resolution. + if let Some(ref secret_ref) = self.spec.rpc_secret { + env_vars.push(corev1::EnvVar { + name: RPC_SECRET_ENV.to_owned(), + value_from: Some(corev1::EnvVarSource { + secret_key_ref: Some(corev1::SecretKeySelector { + name: secret_ref.name.clone(), + key: secret_ref.key.clone(), + optional: Some(false), + }), + ..Default::default() + }), + ..Default::default() + }); + } + // Merge with user-provided environment variables. // Preserve the legacy override behavior except for operator-managed runtime // values that must stay aligned with rendered mounts, probes, status, and hash. @@ -532,6 +551,9 @@ impl Tenant { if is_kms_operator_managed_env_var(&user_env.name) { continue; } + if self.spec.rpc_secret.is_some() && user_env.name == RPC_SECRET_ENV { + continue; + } // Remove any existing var with the same name to allow non-reserved overrides. env_vars.retain(|e| e.name != user_env.name); env_vars.push(user_env.clone()); @@ -1121,6 +1143,7 @@ mod tests { EncryptionConfig, KmsBackendType, LocalKmsConfig, LocalKmsMasterKeySecretRef, }; use crate::types::v1alpha1::logging::{LoggingConfig, LoggingMode}; + use crate::types::v1alpha1::tenant::RpcSecretRef; use crate::types::v1alpha1::tls::{SecretKeyReference, TlsPlan}; use k8s_openapi::api::core::v1 as corev1; @@ -1233,6 +1256,86 @@ mod tests { })); } + #[test] + fn omitted_rpc_secret_leaves_rpc_auth_resolution_to_rustfs() { + let tenant = crate::tests::create_test_tenant(None, None); + let pool = &tenant.spec.pools[0]; + + let statefulset = tenant + .new_statefulset(pool) + .expect("Should create StatefulSet without an RPC Secret"); + let container = &statefulset.spec.unwrap().template.spec.unwrap().containers[0]; + + assert!( + container + .env + .as_ref() + .is_none_or(|env| env.iter().all(|var| var.name != "RUSTFS_RPC_SECRET")) + ); + } + + #[test] + fn rpc_secret_maps_selected_secret_key_and_owns_the_env_var() { + let mut tenant = crate::tests::create_test_tenant(None, None); + tenant.spec.rpc_secret = Some(RpcSecretRef { + name: "tenant-rpc-auth".to_string(), + key: "rpc-secret".to_string(), + }); + tenant.spec.env.push(corev1::EnvVar { + name: "RUSTFS_RPC_SECRET".to_string(), + value: Some("raw-override-must-not-win".to_string()), + ..Default::default() + }); + let pool = &tenant.spec.pools[0]; + + let statefulset = tenant + .new_statefulset(pool) + .expect("Should create StatefulSet with an RPC Secret"); + let container = &statefulset.spec.unwrap().template.spec.unwrap().containers[0]; + let rpc_env = container + .env + .as_ref() + .unwrap() + .iter() + .filter(|var| var.name == "RUSTFS_RPC_SECRET") + .collect::>(); + + assert_eq!(rpc_env.len(), 1); + assert_eq!(rpc_env[0].value, None); + assert_eq!( + rpc_env[0] + .value_from + .as_ref() + .and_then(|source| source.secret_key_ref.as_ref()), + Some(&corev1::SecretKeySelector { + name: "tenant-rpc-auth".to_string(), + key: "rpc-secret".to_string(), + optional: Some(false), + }) + ); + } + + #[test] + fn raw_rpc_secret_env_remains_supported_without_rpc_secret_ref() { + let mut tenant = crate::tests::create_test_tenant(None, None); + tenant.spec.env.push(corev1::EnvVar { + name: "RUSTFS_RPC_SECRET".to_string(), + value: Some("legacy-explicit-rpc-secret".to_string()), + ..Default::default() + }); + let pool = &tenant.spec.pools[0]; + + let statefulset = tenant + .new_statefulset(pool) + .expect("Should preserve the legacy raw RPC Secret environment variable"); + let container = &statefulset.spec.unwrap().template.spec.unwrap().containers[0]; + + assert_eq!( + env_value(container, "RUSTFS_RPC_SECRET"), + Some("legacy-explicit-rpc-secret") + ); + } + #[test] fn cert_manager_tls_statefulset_maps_secret_to_rustfs_tls_files() { let tenant = crate::tests::create_test_tenant(None, None); @@ -2439,6 +2542,30 @@ mod tests { ); } + #[test] + fn test_statefulset_rpc_secret_change_detected() { + let mut tenant = crate::tests::create_test_tenant(None, None); + tenant.spec.rpc_secret = Some(RpcSecretRef { + name: "tenant-rpc-auth".to_string(), + key: "rpc-secret".to_string(), + }); + let pool = &tenant.spec.pools[0]; + let statefulset = tenant + .new_statefulset(pool) + .expect("Should create StatefulSet"); + + tenant.spec.rpc_secret.as_mut().unwrap().name = "rotated-rpc-auth".to_string(); + + let needs_update = tenant + .statefulset_needs_update(&statefulset, pool) + .expect("Should check update need"); + + assert!( + needs_update, + "StatefulSet should need update when the RPC Secret reference changes" + ); + } + // Test: StatefulSet diff detection - resources change #[test] fn test_statefulset_resources_change_detected() { From 25caefe2b214334477a8dd11ba574d34a0e00a32 Mon Sep 17 00:00:00 2001 From: GatewayJ <18332154+GatewayJ@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:21:35 +0800 Subject: [PATCH 2/3] fix(operator): validate dedicated RPC secrets --- deploy/rustfs-operator/README.md | 9 +- src/context.rs | 170 ++++++++++++++++++++++++++++++- src/reconcile.rs | 10 ++ src/reconcile/phases.rs | 8 ++ src/status.rs | 56 ++++++++++ src/types/v1alpha1/status.rs | 23 +++++ 6 files changed, 271 insertions(+), 5 deletions(-) diff --git a/deploy/rustfs-operator/README.md b/deploy/rustfs-operator/README.md index a1eb1e2..c40618c 100755 --- a/deploy/rustfs-operator/README.md +++ b/deploy/rustfs-operator/README.md @@ -104,9 +104,12 @@ spec: ``` The operator maps the selected Secret key to `RUSTFS_RPC_SECRET` in every RustFS -Pod. Keep this value stable while rotating administrator credentials. If -`spec.rpcSecret` is omitted, the operator does not set `RUSTFS_RPC_SECRET` and -RustFS resolves the RPC secret from its own credential configuration. +Pod. Before applying workloads, it verifies that the Secret and selected key +exist and that the value is valid UTF-8, non-blank, and not the RustFS default +credential value (`rustfsadmin`). Keep this value stable while rotating +administrator credentials. If `spec.rpcSecret` is omitted, the operator does not +set `RUSTFS_RPC_SECRET` and RustFS resolves the RPC secret from its own credential +configuration. ### Tenant Provisioning diff --git a/src/context.rs b/src/context.rs index ca730f2..e4e96a4 100755 --- a/src/context.rs +++ b/src/context.rs @@ -15,7 +15,7 @@ use crate::cluster_dns::ClusterDomain; use crate::types; use crate::types::v1alpha1::encryption::LocalKmsMasterKeySecretRef; -use crate::types::v1alpha1::tenant::Tenant; +use crate::types::v1alpha1::tenant::{RpcSecretRef, Tenant}; use k8s_openapi::NamespaceResourceScope; use k8s_openapi::api::core::v1::Secret; use kube::api::{DeleteParams, ListParams, ObjectList, Patch, PatchParams, PostParams}; @@ -65,6 +65,29 @@ pub enum Error { length: usize, }, + #[snafu(display("RPC Secret '{}' not found", name))] + RpcSecretNotFound { name: String }, + + #[snafu(display("spec.rpcSecret.{} must not be blank", field))] + RpcSecretInvalidReference { field: String }, + + #[snafu(display("RPC Secret '{}' missing required key '{}'", secret_name, key))] + RpcSecretMissingKey { secret_name: String, key: String }, + + #[snafu(display( + "RPC Secret '{}' has invalid data encoding for key '{}'", + secret_name, + key + ))] + RpcSecretInvalidEncoding { secret_name: String, key: String }, + + #[snafu(display( + "RPC Secret '{}' key '{}' must be non-blank and must not use the RustFS default credential value", + secret_name, + key + ))] + RpcSecretInvalidValue { secret_name: String, key: String }, + #[snafu(display("KMS secret '{}' not found", name))] KmsSecretNotFound { name: String }, @@ -272,6 +295,46 @@ fn validate_secret_utf8_non_blank( Ok(()) } +const RUSTFS_DEFAULT_CREDENTIAL_VALUE: &str = "rustfsadmin"; + +fn validate_rpc_secret_ref(secret_ref: &RpcSecretRef) -> Result<(), Error> { + for (field, value) in [("name", &secret_ref.name), ("key", &secret_ref.key)] { + if value.trim().is_empty() { + return RpcSecretInvalidReferenceSnafu { + field: field.to_string(), + } + .fail(); + } + } + + Ok(()) +} + +fn validate_rpc_secret_value(secret: &Secret, secret_name: &str, key: &str) -> Result<(), Error> { + let Some(value) = secret.data.as_ref().and_then(|data| data.get(key)) else { + return RpcSecretMissingKeySnafu { + secret_name: secret_name.to_string(), + key: key.to_string(), + } + .fail(); + }; + + let value = std::str::from_utf8(&value.0).map_err(|_| Error::RpcSecretInvalidEncoding { + secret_name: secret_name.to_string(), + key: key.to_string(), + })?; + let value = value.trim(); + if value.is_empty() || value == RUSTFS_DEFAULT_CREDENTIAL_VALUE { + return RpcSecretInvalidValueSnafu { + secret_name: secret_name.to_string(), + key: key.to_string(), + } + .fail(); + } + + Ok(()) +} + fn status_semantically_equal( current: Option<&types::v1alpha1::status::Status>, next: &types::v1alpha1::status::Status, @@ -296,6 +359,7 @@ fn normalize_status_for_compare(status: &mut types::v1alpha1::status::Status) { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum SecretValidationKind { Credential, + Rpc, Kms, } @@ -319,6 +383,7 @@ pub(crate) fn map_secret_get_error( match kind { SecretValidationKind::Credential => Error::CredentialSecretNotFound { name }, + SecretValidationKind::Rpc => Error::RpcSecretNotFound { name }, SecretValidationKind::Kms => Error::KmsSecretNotFound { name }, } } @@ -611,6 +676,32 @@ impl Context { Ok(()) } + /// Validates the dedicated internode RPC authentication Secret when configured. + /// + /// RustFS trims `RUSTFS_RPC_SECRET` and rejects blank values and its public default + /// credential value. Validate those rules before rendering StatefulSets so an invalid + /// Secret cannot start a rollout that leaves Pods unable to authenticate to each other. + pub async fn validate_rpc_secret(&self, tenant: &Tenant) -> Result<(), Error> { + let Some(secret_ref) = tenant.spec.rpc_secret.as_ref() else { + return Ok(()); + }; + + validate_rpc_secret_ref(secret_ref)?; + + let secret: Secret = match self.get(&secret_ref.name, &tenant.namespace()?).await { + Ok(secret) => secret, + Err(error) => { + return Err(map_secret_get_error( + error, + secret_ref.name.clone(), + SecretValidationKind::Rpc, + )); + } + }; + + validate_rpc_secret_value(&secret, &secret_ref.name, &secret_ref.key) + } + /// Validates encryption configuration and the KMS Secret. /// /// Checks: @@ -793,11 +884,12 @@ mod validate_local_kms_tests { use super::{SecretValidationKind, map_secret_get_error}; use super::{ validate_local_kms_master_key_ref, validate_local_kms_tenant, validate_no_reserved_kms_env, - validate_secret_utf8_non_blank, + validate_rpc_secret_ref, validate_rpc_secret_value, validate_secret_utf8_non_blank, }; use crate::types::v1alpha1::encryption::{LocalKmsConfig, LocalKmsMasterKeySecretRef}; use crate::types::v1alpha1::persistence::PersistenceConfig; use crate::types::v1alpha1::pool::Pool; + use crate::types::v1alpha1::tenant::RpcSecretRef; use k8s_openapi::ByteString; use k8s_openapi::api::core::v1 as corev1; use std::collections::BTreeMap; @@ -872,6 +964,80 @@ mod validate_local_kms_tests { assert!(matches!(err, Error::KmsSecretNotFound { name } if name == "kms")); } + #[test] + fn rpc_secret_get_maps_only_404_to_not_found() { + let err = map_secret_get_error( + api_error(404, "NotFound"), + "rpc-auth".to_string(), + SecretValidationKind::Rpc, + ); + + assert!(matches!(err, Error::RpcSecretNotFound { name } if name == "rpc-auth")); + } + + #[test] + fn rpc_secret_ref_rejects_blank_name_and_key() { + for secret_ref in [ + RpcSecretRef { + name: " ".to_string(), + key: "rpc-secret".to_string(), + }, + RpcSecretRef { + name: "rpc-auth".to_string(), + key: "\n".to_string(), + }, + ] { + let err = validate_rpc_secret_ref(&secret_ref).unwrap_err(); + assert!(matches!(err, Error::RpcSecretInvalidReference { .. })); + } + } + + #[test] + fn rpc_secret_value_must_exist_and_be_valid_utf8() { + let mut data = BTreeMap::new(); + data.insert("invalid".to_string(), ByteString(vec![0xff])); + let secret = corev1::Secret { + data: Some(data), + ..Default::default() + }; + + let missing = validate_rpc_secret_value(&secret, "rpc-auth", "missing").unwrap_err(); + assert!(matches!(missing, Error::RpcSecretMissingKey { key, .. } if key == "missing")); + + let invalid = validate_rpc_secret_value(&secret, "rpc-auth", "invalid").unwrap_err(); + assert!(matches!(invalid, Error::RpcSecretInvalidEncoding { key, .. } if key == "invalid")); + } + + #[test] + fn rpc_secret_value_rejects_values_rustfs_cannot_use() { + for value in [b" \n".as_slice(), b" rustfsadmin ".as_slice()] { + let mut data = BTreeMap::new(); + data.insert("rpc-secret".to_string(), ByteString(value.to_vec())); + let secret = corev1::Secret { + data: Some(data), + ..Default::default() + }; + + let err = validate_rpc_secret_value(&secret, "rpc-auth", "rpc-secret").unwrap_err(); + assert!(matches!(err, Error::RpcSecretInvalidValue { .. })); + } + } + + #[test] + fn rpc_secret_value_accepts_non_default_non_blank_utf8() { + let mut data = BTreeMap::new(); + data.insert( + "rpc-secret".to_string(), + ByteString(b"dedicated-rpc-secret".to_vec()), + ); + let secret = corev1::Secret { + data: Some(data), + ..Default::default() + }; + + validate_rpc_secret_value(&secret, "rpc-auth", "rpc-secret").unwrap(); + } + #[test] fn local_kms_default_key_dir_ok_single_replica() { validate_local_kms_tenant(None, &[pool(1)]).unwrap(); diff --git a/src/reconcile.rs b/src/reconcile.rs index 106e133..012cba8 100755 --- a/src/reconcile.rs +++ b/src/reconcile.rs @@ -888,6 +888,11 @@ pub fn error_policy(object: Arc, error: &Error, _ctx: Arc) -> A | context::Error::CredentialSecretMissingKey { .. } | context::Error::CredentialSecretInvalidEncoding { .. } | context::Error::CredentialSecretTooShort { .. } + | context::Error::RpcSecretNotFound { .. } + | context::Error::RpcSecretInvalidReference { .. } + | context::Error::RpcSecretMissingKey { .. } + | context::Error::RpcSecretInvalidEncoding { .. } + | context::Error::RpcSecretInvalidValue { .. } | context::Error::KmsSecretNotFound { .. } | context::Error::KmsSecretMissingKey { .. } | context::Error::KmsConfigInvalid { .. } => Duration::from_secs(60), @@ -938,6 +943,11 @@ fn reconcile_error_reason(error: &Error) -> &'static str { "CredentialSecretInvalidEncoding" } context::Error::CredentialSecretTooShort { .. } => "CredentialSecretTooShort", + context::Error::RpcSecretNotFound { .. } => "RpcSecretNotFound", + context::Error::RpcSecretInvalidReference { .. } => "RpcSecretInvalidReference", + context::Error::RpcSecretMissingKey { .. } => "RpcSecretMissingKey", + context::Error::RpcSecretInvalidEncoding { .. } => "RpcSecretInvalidEncoding", + context::Error::RpcSecretInvalidValue { .. } => "RpcSecretInvalidValue", context::Error::KmsSecretNotFound { .. } => "KmsSecretNotFound", context::Error::KmsSecretMissingKey { .. } => "KmsSecretMissingKey", context::Error::KmsConfigInvalid { .. } => "KmsConfigInvalid", diff --git a/src/reconcile/phases.rs b/src/reconcile/phases.rs index 56d2a4b..e35573d 100644 --- a/src/reconcile/phases.rs +++ b/src/reconcile/phases.rs @@ -94,6 +94,14 @@ pub(super) async fn validate_tenant_prerequisites( return Err(e.into()); } + // Validate dedicated internode RPC authentication before applying workloads. An invalid + // Secret would otherwise only fail when Kubernetes starts a Pod, after rollout has begun. + if let Err(e) = ctx.validate_rpc_secret(tenant).await { + let status_error = StatusError::from_context_error(&e); + patch_status_error(ctx, tenant, &status_error).await; + return Err(e.into()); + } + // Validate encryption / KMS and reject raw RUSTFS_KMS_* env overrides even when // spec.encryption is omitted or disabled. if let Err(e) = ctx.validate_kms_secret(tenant).await { diff --git a/src/status.rs b/src/status.rs index a6ea308..091e154 100644 --- a/src/status.rs +++ b/src/status.rs @@ -74,6 +74,40 @@ impl StatusError { secret_name, key ), ), + context::Error::RpcSecretNotFound { name } => Self::blocked( + Reason::RpcSecretNotFound, + ConditionType::RpcAuthReady, + format!("RPC Secret '{}' was not found", name), + ), + context::Error::RpcSecretInvalidReference { field } => Self::blocked( + Reason::RpcSecretInvalidReference, + ConditionType::RpcAuthReady, + format!("spec.rpcSecret.{} must not be blank", field), + ), + context::Error::RpcSecretMissingKey { secret_name, key } => Self::blocked( + Reason::RpcSecretMissingKey, + ConditionType::RpcAuthReady, + format!( + "RPC Secret '{}' is missing required key '{}'", + secret_name, key + ), + ), + context::Error::RpcSecretInvalidEncoding { secret_name, key } => Self::blocked( + Reason::RpcSecretInvalidEncoding, + ConditionType::RpcAuthReady, + format!( + "RPC Secret '{}' key '{}' must contain valid UTF-8", + secret_name, key + ), + ), + context::Error::RpcSecretInvalidValue { secret_name, key } => Self::blocked( + Reason::RpcSecretInvalidValue, + ConditionType::RpcAuthReady, + format!( + "RPC Secret '{}' key '{}' must be non-blank and must not use the RustFS default credential value", + secret_name, key + ), + ), context::Error::KmsSecretNotFound { name } => Self::blocked( Reason::KmsSecretNotFound, ConditionType::KmsReady, @@ -530,6 +564,7 @@ impl StatusBuilder { for condition_type in [ ConditionType::SpecValid, ConditionType::CredentialsReady, + ConditionType::RpcAuthReady, ConditionType::KmsReady, ConditionType::PoolsReady, ConditionType::WorkloadsReady, @@ -620,6 +655,27 @@ mod tests { assert!(status.condition(ConditionType::WorkloadsReady).is_none()); } + #[test] + fn status_builder_maps_rpc_secret_invalid_value() { + let tenant = crate::tests::create_test_tenant(None, None); + let err = context::Error::RpcSecretInvalidValue { + secret_name: "rpc-auth".to_string(), + key: "rpc-secret".to_string(), + }; + + let status_error = StatusError::from_context_error(&err); + let mut builder = StatusBuilder::from_tenant(&tenant); + builder.mark_error(&status_error); + let status = builder.build(); + + let condition = status.condition(ConditionType::RpcAuthReady).unwrap(); + assert_eq!(condition.status, "False"); + assert_eq!(condition.reason, "RpcSecretInvalidValue"); + assert_eq!(status.current_state, "Blocked"); + assert!(status.condition(ConditionType::CredentialsReady).is_none()); + assert!(status.condition(ConditionType::WorkloadsReady).is_none()); + } + #[test] fn transition_time_is_preserved_when_status_does_not_change() { let mut status = Status { diff --git a/src/types/v1alpha1/status.rs b/src/types/v1alpha1/status.rs index a51b290..6a93fe8 100755 --- a/src/types/v1alpha1/status.rs +++ b/src/types/v1alpha1/status.rs @@ -26,6 +26,7 @@ pub enum ConditionType { Degraded, SpecValid, CredentialsReady, + RpcAuthReady, KmsReady, TlsReady, PoolsReady, @@ -41,6 +42,7 @@ impl ConditionType { Self::Degraded => "Degraded", Self::SpecValid => "SpecValid", Self::CredentialsReady => "CredentialsReady", + Self::RpcAuthReady => "RpcAuthReady", Self::KmsReady => "KmsReady", Self::TlsReady => "TlsReady", Self::PoolsReady => "PoolsReady", @@ -56,6 +58,7 @@ impl ConditionType { Self::Degraded, Self::SpecValid, Self::CredentialsReady, + Self::RpcAuthReady, Self::KmsReady, Self::TlsReady, Self::PoolsReady, @@ -118,6 +121,11 @@ pub enum Reason { CredentialSecretMissingKey, CredentialSecretInvalidEncoding, CredentialSecretTooShort, + RpcSecretNotFound, + RpcSecretInvalidReference, + RpcSecretMissingKey, + RpcSecretInvalidEncoding, + RpcSecretInvalidValue, KmsSecretNotFound, KmsSecretMissingKey, KmsConfigInvalid, @@ -180,6 +188,11 @@ impl Reason { Self::CredentialSecretMissingKey => "CredentialSecretMissingKey", Self::CredentialSecretInvalidEncoding => "CredentialSecretInvalidEncoding", Self::CredentialSecretTooShort => "CredentialSecretTooShort", + Self::RpcSecretNotFound => "RpcSecretNotFound", + Self::RpcSecretInvalidReference => "RpcSecretInvalidReference", + Self::RpcSecretMissingKey => "RpcSecretMissingKey", + Self::RpcSecretInvalidEncoding => "RpcSecretInvalidEncoding", + Self::RpcSecretInvalidValue => "RpcSecretInvalidValue", Self::KmsSecretNotFound => "KmsSecretNotFound", Self::KmsSecretMissingKey => "KmsSecretMissingKey", Self::KmsConfigInvalid => "KmsConfigInvalid", @@ -459,6 +472,11 @@ pub fn is_blocked_reason(reason: &str) -> bool { | "CredentialSecretMissingKey" | "CredentialSecretInvalidEncoding" | "CredentialSecretTooShort" + | "RpcSecretNotFound" + | "RpcSecretInvalidReference" + | "RpcSecretMissingKey" + | "RpcSecretInvalidEncoding" + | "RpcSecretInvalidValue" | "KmsSecretNotFound" | "KmsSecretMissingKey" | "KmsConfigInvalid" @@ -517,6 +535,11 @@ pub fn next_actions_for_reason(reason: &str) -> Vec<&'static str> { "CredentialSecretMissingKey" => vec!["addRequiredSecretKey"], "CredentialSecretInvalidEncoding" => vec!["replaceSecretValueWithUtf8"], "CredentialSecretTooShort" => vec!["rotateCredentialSecret"], + "RpcSecretNotFound" => vec!["createRpcSecret"], + "RpcSecretInvalidReference" => vec!["fixRpcSecretRef"], + "RpcSecretMissingKey" => vec!["addRequiredRpcSecretKey"], + "RpcSecretInvalidEncoding" => vec!["replaceRpcSecretValueWithUtf8"], + "RpcSecretInvalidValue" => vec!["rotateRpcSecret"], "KmsSecretNotFound" => vec!["createKmsSecret"], "KmsSecretMissingKey" => vec!["addRequiredKmsSecretKey"], "KmsConfigInvalid" => vec!["fixKmsConfig"], From af6653bdf14837db63c09d0123b0b0cc223a2a00 Mon Sep 17 00:00:00 2001 From: GatewayJ <18332154+GatewayJ@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:37:19 +0800 Subject: [PATCH 3/3] fix(operator): harden RPC secret readiness --- deploy/rustfs-operator/README.md | 31 +++++++-- deploy/rustfs-operator/crds/tenant-crd.yaml | 4 +- deploy/rustfs-operator/crds/tenant.yaml | 4 +- docs/operator-user-guide.md | 38 ++++++++++ docs/operator-user-guide.zh-CN.md | 36 ++++++++++ e2e/src/framework/resources.rs | 54 ++++++++++++++- e2e/src/framework/tenant_factory.rs | 6 +- e2e/tests/smoke.rs | 13 ++-- src/context.rs | 15 ++-- src/lib.rs | 15 ++-- src/status.rs | 77 ++++++++++++++++++++- src/types/v1alpha1/tenant.rs | 4 +- 12 files changed, 266 insertions(+), 31 deletions(-) diff --git a/deploy/rustfs-operator/README.md b/deploy/rustfs-operator/README.md index c40618c..358623d 100755 --- a/deploy/rustfs-operator/README.md +++ b/deploy/rustfs-operator/README.md @@ -95,6 +95,23 @@ Use `spec.rpcSecret` to keep RustFS internode RPC authentication independent fro the administrator credentials in `spec.credsSecret`: ```yaml +apiVersion: v1 +kind: Secret +metadata: + name: rustfs-rpc-auth + namespace: storage + labels: + # Lets Secret updates enqueue this Tenant for prompt revalidation. + rustfs.tenant: rustfs-a +type: Opaque +stringData: + rpc-secret: "replace-with-a-dedicated-rpc-secret" +--- +apiVersion: rustfs.com/v1alpha1 +kind: Tenant +metadata: + name: rustfs-a + namespace: storage spec: credsSecret: name: rustfs-admin-creds @@ -105,11 +122,15 @@ spec: The operator maps the selected Secret key to `RUSTFS_RPC_SECRET` in every RustFS Pod. Before applying workloads, it verifies that the Secret and selected key -exist and that the value is valid UTF-8, non-blank, and not the RustFS default -credential value (`rustfsadmin`). Keep this value stable while rotating -administrator credentials. If `spec.rpcSecret` is omitted, the operator does not -set `RUSTFS_RPC_SECRET` and RustFS resolves the RPC secret from its own credential -configuration. +exist and that the value is valid UTF-8, non-blank, contains no NUL bytes, and is +not the RustFS default credential value (`rustfsadmin`). Keep this value stable +while rotating administrator credentials. The `rustfs.tenant` label is not used +for authorization; it lets updates to an externally managed Secret enqueue the +Tenant for revalidation. A Secret update does not change the environment of +already-running Pods. Coordinated restart and hot reload are outside this +feature. If `spec.rpcSecret` is omitted, the operator does not set +`RUSTFS_RPC_SECRET`, RustFS resolves it from its own credential configuration, +and the operator does not report `RpcAuthReady` for that unmanaged value. ### Tenant Provisioning diff --git a/deploy/rustfs-operator/crds/tenant-crd.yaml b/deploy/rustfs-operator/crds/tenant-crd.yaml index 436ea80..ea495a1 100644 --- a/deploy/rustfs-operator/crds/tenant-crd.yaml +++ b/deploy/rustfs-operator/crds/tenant-crd.yaml @@ -1417,7 +1417,9 @@ spec: When configured, the operator maps this key to `RUSTFS_RPC_SECRET` for every RustFS Pod. Keep it stable while rotating `credsSecret`. When omitted, the operator does not set `RUSTFS_RPC_SECRET`; RustFS resolves it from its own - credential configuration. + credential configuration. Label an externally managed Secret with + `rustfs.tenant=` so Secret updates enqueue the Tenant for + prompt revalidation. nullable: true properties: key: diff --git a/deploy/rustfs-operator/crds/tenant.yaml b/deploy/rustfs-operator/crds/tenant.yaml index 436ea80..ea495a1 100755 --- a/deploy/rustfs-operator/crds/tenant.yaml +++ b/deploy/rustfs-operator/crds/tenant.yaml @@ -1417,7 +1417,9 @@ spec: When configured, the operator maps this key to `RUSTFS_RPC_SECRET` for every RustFS Pod. Keep it stable while rotating `credsSecret`. When omitted, the operator does not set `RUSTFS_RPC_SECRET`; RustFS resolves it from its own - credential configuration. + credential configuration. Label an externally managed Secret with + `rustfs.tenant=` so Secret updates enqueue the Tenant for + prompt revalidation. nullable: true properties: key: diff --git a/docs/operator-user-guide.md b/docs/operator-user-guide.md index 4043641..4bd9b00 100644 --- a/docs/operator-user-guide.md +++ b/docs/operator-user-guide.md @@ -323,6 +323,44 @@ Credential priority: 2. Explicit `RUSTFS_ACCESS_KEY` and `RUSTFS_SECRET_KEY` in `spec.env`. 3. RustFS built-in defaults. Use defaults only for development. +#### Dedicated internode RPC Secret + +For production, configure `spec.rpcSecret` so internode RPC authentication does +not depend on the administrator credentials. Keep the Secret in the Tenant +namespace and label externally managed Secrets with the Tenant name: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: rustfs-rpc-auth + namespace: storage + labels: + rustfs.tenant: rustfs-a +type: Opaque +stringData: + rpc-secret: "replace-with-a-dedicated-rpc-secret" +--- +apiVersion: rustfs.com/v1alpha1 +kind: Tenant +metadata: + name: rustfs-a + namespace: storage +spec: + rpcSecret: + name: rustfs-rpc-auth + key: rpc-secret + # pools: ... +``` + +The selected value must be valid UTF-8, non-blank, contain no NUL bytes, and +must not be `rustfsadmin`. The `rustfs.tenant` label lets Secret updates enqueue +the Tenant for prompt revalidation; it is not an authorization mechanism. When +the configured Secret passes validation, the Operator reports +`RpcAuthReady=True`. Updating the Secret does not change the environment of +already-running Pods; coordinated restart and hot reload are outside this +feature. + ### 7.4 Workload Settings Useful Tenant-level fields: diff --git a/docs/operator-user-guide.zh-CN.md b/docs/operator-user-guide.zh-CN.md index 72b4e8d..c65408a 100644 --- a/docs/operator-user-guide.zh-CN.md +++ b/docs/operator-user-guide.zh-CN.md @@ -325,6 +325,42 @@ spec: 2. `spec.env` 中显式配置 `RUSTFS_ACCESS_KEY` 和 `RUSTFS_SECRET_KEY`。 3. RustFS 内置默认值。默认值仅适合开发测试。 +#### 独立的节点间 RPC Secret + +生产环境建议配置 `spec.rpcSecret`,避免节点间 RPC 认证依赖管理员凭据。 +Secret 必须与 Tenant 位于同一 namespace;若 Secret 由外部系统管理,请添加 +Tenant 标签: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: rustfs-rpc-auth + namespace: storage + labels: + rustfs.tenant: rustfs-a +type: Opaque +stringData: + rpc-secret: "replace-with-a-dedicated-rpc-secret" +--- +apiVersion: rustfs.com/v1alpha1 +kind: Tenant +metadata: + name: rustfs-a + namespace: storage +spec: + rpcSecret: + name: rustfs-rpc-auth + key: rpc-secret + # pools: ... +``` + +所选值必须是有效 UTF-8、不能为空、不能包含 NUL 字节,并且不能是 +`rustfsadmin`。`rustfs.tenant` 标签仅用于在 Secret 变化时及时触发 Tenant +重新校验,不是授权机制。配置的 Secret 校验通过后,Operator 会报告 +`RpcAuthReady=True`。更新 Secret 不会改变已运行 Pod 的进程环境;协调重启和 +热加载不在此功能范围内。 + ### 7.4 工作负载配置 常用 Tenant 级字段: diff --git a/e2e/src/framework/resources.rs b/e2e/src/framework/resources.rs index 5288547..a1119c3 100644 --- a/e2e/src/framework/resources.rs +++ b/e2e/src/framework/resources.rs @@ -23,9 +23,12 @@ use crate::framework::{ tenant_factory::TenantTemplate, }; use operator::types::v1alpha1::k8s::PodManagementPolicy; +use operator::types::v1alpha1::tenant::RpcSecretRef; const TEST_ACCESS_KEY: &str = "testaccess"; const TEST_SECRET_KEY: &str = "testsecret"; +const TEST_RPC_SECRET: &str = "test-dedicated-rpc-secret"; +const RPC_SECRET_KEY: &str = "rpc-secret"; const RESOURCE_RESET_TIMEOUT: Duration = Duration::from_secs(120); const RESOURCE_RESET_POLL_INTERVAL: Duration = Duration::from_secs(2); @@ -33,6 +36,10 @@ pub fn credential_secret_name(config: &ClusterTestConfig) -> String { format!("{}-credentials", config.tenant_name) } +pub fn rpc_secret_name(config: &ClusterTestConfig) -> String { + format!("{}-rpc-auth", config.tenant_name) +} + pub fn test_credentials() -> (&'static str, &'static str) { (TEST_ACCESS_KEY, TEST_SECRET_KEY) } @@ -66,6 +73,27 @@ stringData: ) } +pub fn rpc_secret_manifest(config: &ClusterTestConfig) -> String { + format!( + r#"apiVersion: v1 +kind: Secret +metadata: + name: {secret_name} + namespace: {namespace} + labels: + rustfs.tenant: {tenant_name} +type: Opaque +stringData: + {key}: {rpc_secret} +"#, + secret_name = rpc_secret_name(config), + namespace = config.test_namespace, + tenant_name = config.tenant_name, + key = RPC_SECRET_KEY, + rpc_secret = TEST_RPC_SECRET, + ) +} + pub fn smoke_tenant_template(config: &ClusterTestConfig) -> TenantTemplate { let mut template = TenantTemplate::kind_local( &config.test_namespace, @@ -81,6 +109,10 @@ pub fn smoke_tenant_template(config: &ClusterTestConfig) -> TenantTemplate { .clone() .unwrap_or(PodManagementPolicy::Parallel), ); + template.rpc_secret = Some(RpcSecretRef { + name: rpc_secret_name(config), + key: RPC_SECRET_KEY.to_string(), + }); template } @@ -99,6 +131,9 @@ pub fn apply_smoke_tenant_resources(config: &ClusterTestConfig) -> Result<()> { kubectl .apply_yaml_command(credential_secret_manifest(config)) .run_checked()?; + kubectl + .apply_yaml_command(rpc_secret_manifest(config)) + .run_checked()?; kubectl .apply_yaml_command(smoke_tenant_manifest(config)?) .run_checked()?; @@ -304,7 +339,10 @@ fn is_not_found(output: &CommandOutput) -> bool { #[cfg(test)] mod tests { - use super::{credential_secret_manifest, credential_secret_name, smoke_tenant_manifest}; + use super::{ + credential_secret_manifest, credential_secret_name, rpc_secret_manifest, rpc_secret_name, + smoke_tenant_manifest, + }; use crate::framework::config::E2eConfig; #[test] @@ -317,6 +355,9 @@ mod tests { assert!(manifest.contains("image: rustfs/rustfs:latest")); assert!(manifest.contains("storageClassName: local-storage")); assert!(manifest.contains("name: e2e-tenant-credentials")); + assert!(manifest.contains("rpcSecret:")); + assert!(manifest.contains("name: e2e-tenant-rpc-auth")); + assert!(manifest.contains("key: rpc-secret")); } #[test] @@ -329,4 +370,15 @@ mod tests { assert!(manifest.contains("accesskey:")); assert!(manifest.contains("secretkey:")); } + + #[test] + fn rpc_secret_uses_tenant_watch_label() { + let config = E2eConfig::defaults(); + let manifest = rpc_secret_manifest(&config); + + assert_eq!(rpc_secret_name(&config), "e2e-tenant-rpc-auth"); + assert!(manifest.contains("namespace: rustfs-e2e-smoke")); + assert!(manifest.contains("rustfs.tenant: e2e-tenant")); + assert!(manifest.contains("rpc-secret: test-dedicated-rpc-secret")); + } } diff --git a/e2e/src/framework/tenant_factory.rs b/e2e/src/framework/tenant_factory.rs index af806d3..8328e58 100644 --- a/e2e/src/framework/tenant_factory.rs +++ b/e2e/src/framework/tenant_factory.rs @@ -22,7 +22,7 @@ use operator::types::v1alpha1::k8s::ImagePullPolicy; use operator::types::v1alpha1::k8s::PodManagementPolicy; use operator::types::v1alpha1::persistence::PersistenceConfig; use operator::types::v1alpha1::pool::{Pool, SchedulingConfig}; -use operator::types::v1alpha1::tenant::{Tenant, TenantSpec}; +use operator::types::v1alpha1::tenant::{RpcSecretRef, Tenant, TenantSpec}; use std::collections::BTreeMap; #[derive(Debug, Clone)] @@ -32,6 +32,7 @@ pub struct TenantTemplate { pub image: String, pub storage_class: String, pub credential_secret_name: String, + pub rpc_secret: Option, pub servers: i32, pub volumes_per_server: i32, pub storage_request: String, @@ -55,6 +56,7 @@ impl TenantTemplate { image: image.into(), storage_class: storage_class.into(), credential_secret_name: credential_secret_name.into(), + rpc_secret: None, servers: 4, volumes_per_server: 2, storage_request: "10Gi".to_string(), @@ -83,6 +85,7 @@ impl TenantTemplate { image: image.into(), storage_class: storage_class.into(), credential_secret_name: credential_secret_name.into(), + rpc_secret: None, servers: 4, volumes_per_server: 1, storage_request: "100Gi".to_string(), @@ -146,6 +149,7 @@ impl TenantTemplate { creds_secret: Some(LocalObjectReference { name: self.credential_secret_name.clone(), }), + rpc_secret: self.rpc_secret.clone(), env, ..TenantSpec::default() }; diff --git a/e2e/tests/smoke.rs b/e2e/tests/smoke.rs index 9ae9d60..3acc861 100644 --- a/e2e/tests/smoke.rs +++ b/e2e/tests/smoke.rs @@ -12,10 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -use anyhow::Result; +use anyhow::{Result, ensure}; use rustfs_operator_e2e::framework::{ - artifacts::ArtifactCollector, config::E2eConfig, deploy, kube_client, live, resources, storage, - tools::required_tool_checks, wait, + artifacts::ArtifactCollector, assertions, config::E2eConfig, deploy, kube_client, live, + resources, storage, tools::required_tool_checks, wait, }; #[test] @@ -57,7 +57,12 @@ async fn smoke_apply_tenant_and_wait_ready() -> Result<()> { let client = kube_client::default_client().await?; let tenants = kube_client::tenant_api(client, &config.test_namespace); - wait::wait_for_tenant_ready(tenants, &config.tenant_name, config.timeout).await?; + let tenant = + wait::wait_for_tenant_ready(tenants, &config.tenant_name, config.timeout).await?; + ensure!( + assertions::condition_status(&tenant, "RpcAuthReady") == Some("True"), + "Tenant became Ready without RpcAuthReady=True" + ); Ok(()) } .await; diff --git a/src/context.rs b/src/context.rs index e4e96a4..d8d052e 100755 --- a/src/context.rs +++ b/src/context.rs @@ -82,7 +82,7 @@ pub enum Error { RpcSecretInvalidEncoding { secret_name: String, key: String }, #[snafu(display( - "RPC Secret '{}' key '{}' must be non-blank and must not use the RustFS default credential value", + "RPC Secret '{}' key '{}' must be non-blank, contain no NUL bytes, and must not use the RustFS default credential value", secret_name, key ))] @@ -324,7 +324,7 @@ fn validate_rpc_secret_value(secret: &Secret, secret_name: &str, key: &str) -> R key: key.to_string(), })?; let value = value.trim(); - if value.is_empty() || value == RUSTFS_DEFAULT_CREDENTIAL_VALUE { + if value.is_empty() || value.contains('\0') || value == RUSTFS_DEFAULT_CREDENTIAL_VALUE { return RpcSecretInvalidValueSnafu { secret_name: secret_name.to_string(), key: key.to_string(), @@ -679,8 +679,9 @@ impl Context { /// Validates the dedicated internode RPC authentication Secret when configured. /// /// RustFS trims `RUSTFS_RPC_SECRET` and rejects blank values and its public default - /// credential value. Validate those rules before rendering StatefulSets so an invalid - /// Secret cannot start a rollout that leaves Pods unable to authenticate to each other. + /// credential value; process environments also cannot represent NUL bytes. Validate + /// those rules before rendering StatefulSets so an invalid Secret cannot start a + /// rollout that leaves Pods unable to authenticate to each other. pub async fn validate_rpc_secret(&self, tenant: &Tenant) -> Result<(), Error> { let Some(secret_ref) = tenant.spec.rpc_secret.as_ref() else { return Ok(()); @@ -1010,7 +1011,11 @@ mod validate_local_kms_tests { #[test] fn rpc_secret_value_rejects_values_rustfs_cannot_use() { - for value in [b" \n".as_slice(), b" rustfsadmin ".as_slice()] { + for value in [ + b" \n".as_slice(), + b" rustfsadmin ".as_slice(), + b"valid\0secret".as_slice(), + ] { let mut data = BTreeMap::new(); data.insert("rpc-secret".to_string(), ByteString(value.to_vec())); let secret = corev1::Secret { diff --git a/src/lib.rs b/src/lib.rs index b8b76af..811cee4 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -735,18 +735,15 @@ mod controller_watch_tests { } #[test] - fn secret_mapper_uses_rustfs_tenant_label_for_cert_manager_output_secret() { + fn secret_mapper_uses_rustfs_tenant_label_for_external_tenant_secret() { let secret = corev1::Secret { metadata: metav1::ObjectMeta { - name: Some("server-tls".to_string()), + name: Some("rustfs-rpc-auth".to_string()), namespace: Some("storage".to_string()), - labels: Some(BTreeMap::from([ - ( - "app.kubernetes.io/managed-by".to_string(), - "rustfs-operator".to_string(), - ), - ("rustfs.tenant".to_string(), "tenant-b".to_string()), - ])), + labels: Some(BTreeMap::from([( + "rustfs.tenant".to_string(), + "tenant-b".to_string(), + )])), ..Default::default() }, ..Default::default() diff --git a/src/status.rs b/src/status.rs index 091e154..81cf687 100644 --- a/src/status.rs +++ b/src/status.rs @@ -104,7 +104,7 @@ impl StatusError { Reason::RpcSecretInvalidValue, ConditionType::RpcAuthReady, format!( - "RPC Secret '{}' key '{}' must be non-blank and must not use the RustFS default credential value", + "RPC Secret '{}' key '{}' must be non-blank, contain no NUL bytes, and must not use the RustFS default credential value", secret_name, key ), ), @@ -261,6 +261,7 @@ impl StatusError { pub struct StatusBuilder { generation: Option, now: String, + rpc_secret_configured: bool, next: Status, } @@ -269,6 +270,7 @@ impl StatusBuilder { Self { generation: tenant.metadata.generation, now: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + rpc_secret_configured: tenant.spec.rpc_secret.is_some(), next: tenant.status.clone().unwrap_or_default(), } } @@ -564,7 +566,6 @@ impl StatusBuilder { for condition_type in [ ConditionType::SpecValid, ConditionType::CredentialsReady, - ConditionType::RpcAuthReady, ConditionType::KmsReady, ConditionType::PoolsReady, ConditionType::WorkloadsReady, @@ -577,6 +578,18 @@ impl StatusBuilder { format!("{} is ready", condition_type.as_str()), ); } + + if self.rpc_secret_configured { + self.set_condition( + ConditionType::RpcAuthReady, + ConditionStatus::True, + Reason::ReconcileSucceeded, + "Configured RPC Secret is valid".to_string(), + ); + } else { + self.next + .remove_condition_by_type(ConditionType::RpcAuthReady.as_str()); + } } fn clear_stale_blocked_conditions( @@ -657,7 +670,11 @@ mod tests { #[test] fn status_builder_maps_rpc_secret_invalid_value() { - let tenant = crate::tests::create_test_tenant(None, None); + let mut tenant = crate::tests::create_test_tenant(None, None); + tenant.spec.rpc_secret = Some(crate::types::v1alpha1::tenant::RpcSecretRef { + name: "rpc-auth".to_string(), + key: "rpc-secret".to_string(), + }); let err = context::Error::RpcSecretInvalidValue { secret_name: "rpc-auth".to_string(), key: "rpc-secret".to_string(), @@ -676,6 +693,60 @@ mod tests { assert!(status.condition(ConditionType::WorkloadsReady).is_none()); } + #[test] + fn successful_status_reports_rpc_auth_ready_only_for_managed_secret() { + let mut unmanaged = crate::tests::create_test_tenant(None, None); + unmanaged.spec.env.push(k8s_openapi::api::core::v1::EnvVar { + name: "RUSTFS_RPC_SECRET".to_string(), + value: Some("legacy-user-value".to_string()), + ..Default::default() + }); + let mut builder = StatusBuilder::from_tenant(&unmanaged); + builder.finish_success(); + let status = builder.build(); + + assert!(status.condition(ConditionType::RpcAuthReady).is_none()); + + let mut managed = crate::tests::create_test_tenant(None, None); + managed.spec.rpc_secret = Some(crate::types::v1alpha1::tenant::RpcSecretRef { + name: "rpc-auth".to_string(), + key: "rpc-secret".to_string(), + }); + let mut builder = StatusBuilder::from_tenant(&managed); + builder.finish_success(); + let status = builder.build(); + + let condition = status.condition(ConditionType::RpcAuthReady).unwrap(); + assert_eq!(condition.status, "True"); + assert_eq!(condition.message, "Configured RPC Secret is valid"); + } + + #[test] + fn successful_status_prunes_rpc_auth_ready_after_secret_is_unconfigured() { + let mut tenant = crate::tests::create_test_tenant(None, None); + tenant.metadata.generation = Some(2); + tenant.status = Some(Status { + observed_generation: Some(1), + conditions: vec![condition( + ConditionType::RpcAuthReady.as_str(), + "True", + "ReconcileSucceeded", + )], + ..Default::default() + }); + + let mut builder = StatusBuilder::from_tenant(&tenant); + builder.finish_success(); + let status = builder.build(); + + assert!( + status + .conditions + .iter() + .all(|condition| condition.type_ != ConditionType::RpcAuthReady.as_str()) + ); + } + #[test] fn transition_time_is_preserved_when_status_does_not_change() { let mut status = Status { diff --git a/src/types/v1alpha1/tenant.rs b/src/types/v1alpha1/tenant.rs index 4fea7b6..4789de7 100755 --- a/src/types/v1alpha1/tenant.rs +++ b/src/types/v1alpha1/tenant.rs @@ -185,7 +185,9 @@ pub struct TenantSpec { /// When configured, the operator maps this key to `RUSTFS_RPC_SECRET` for every /// RustFS Pod. Keep it stable while rotating `credsSecret`. When omitted, the /// operator does not set `RUSTFS_RPC_SECRET`; RustFS resolves it from its own - /// credential configuration. + /// credential configuration. Label an externally managed Secret with + /// `rustfs.tenant=` so Secret updates enqueue the Tenant for + /// prompt revalidation. #[serde(default, skip_serializing_if = "Option::is_none")] pub rpc_secret: Option,