Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ All notable changes to RustFS Operator are documented in this file. The format i
- Tenant `spec.hostUsers` and OpenShift `hostUsers: false` defaults for `restricted-v3`.
- Tenant bucket canned anonymous access and ConfigMap-sourced bucket policies.
- Declarative Tenant bucket lifecycle rules with drift-aware ownership and explicit removal.
- Bucket lifecycle `transition` and `noncurrentVersionTransition` actions that move objects to a
remote tier registered in RustFS.
- Declarative Tenant bucket versioning and Object Lock default retention with drift-aware ownership.
- Helm chart `cosiDriver.enabled` to deploy the RustFS COSI (`rustfs.objectstorage.k8s.io`)
driver alongside the upstream provisioner sidecar, with dedicated ServiceAccount/RBAC.
Expand Down
8 changes: 8 additions & 0 deletions console-web/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ export interface BucketLifecycleRule {
abortIncompleteMultipartUpload?: {
daysAfterInitiation: number
}
transition?: {
days: number
storageClass: string
}
noncurrentVersionTransition?: {
noncurrentDays: number
storageClass: string
}
}

export interface ProvisioningItemStatus {
Expand Down
19 changes: 14 additions & 5 deletions crates/rustfs-admin/src/admin_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@
//! - user CRUD and policy queries/expression on `/rustfs/admin/v3/*`
//! - only admin protocol semantics live here; transport/signing is delegated.

use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};

use super::helpers::{
body_mentions_not_found, build_canonical_query, extract_canned_policy_document,
is_absent_resource,
is_absent_resource, parse_tier_names,
};
use super::{
ADD_CANNED_POLICY_PATH, ADD_USER_PATH, ADMIN_SIGNING_SERVICE, INFO_CANNED_POLICY_PATH,
JSON_CONTENT_TYPE, LIST_CANNED_POLICIES_PATH, REMOVE_USER_PATH, RustfsAdminClient,
RustfsClientError, RustfsServerInfo, RustfsServerInfoResponse, RustfsUserInfo,
SERVER_INFO_PATH, SET_POLICY_PATH, USER_INFO_PATH,
JSON_CONTENT_TYPE, LIST_CANNED_POLICIES_PATH, LIST_TIERS_PATH, REMOVE_USER_PATH,
RustfsAdminClient, RustfsClientError, RustfsServerInfo, RustfsServerInfoResponse,
RustfsUserInfo, SERVER_INFO_PATH, SET_POLICY_PATH, USER_INFO_PATH,
};
use reqwest::StatusCode;
use serde_json::Value;
Expand Down Expand Up @@ -175,6 +175,15 @@ impl RustfsAdminClient {
.collect()
}

/// Names of the remote tiers registered in RustFS. Only names are kept; the response also
/// carries tier endpoints and credentials, which are dropped while parsing.
pub async fn list_tier_names(&self) -> Result<BTreeSet<String>, RustfsClientError> {
let body = self
.send_admin_request("GET", LIST_TIERS_PATH, "", "", None)
.await?;
parse_tier_names(&body)
}

pub async fn server_info(&self) -> Result<RustfsServerInfo, RustfsClientError> {
let body = self
.send_admin_request("GET", SERVER_INFO_PATH, "", "", None)
Expand Down
29 changes: 29 additions & 0 deletions crates/rustfs-admin/src/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
// limitations under the License.

//! Internal helper duties: shared credential parsing, signature/hash utilities, and parsers.
use std::collections::BTreeSet;

use hmac::{Hmac, Mac};
use reqwest::StatusCode;
use serde_json::Value;
Expand Down Expand Up @@ -150,6 +152,33 @@ pub(super) fn extract_canned_policy_document(body: &str) -> Result<String, Rustf
serde_json::to_string(policy).map_err(|_| RustfsClientError::InvalidPolicyDocument)
}

/// Parses `GET /rustfs/admin/v3/tier`, an array of `{"type": "<t>", "<t>": {"name": ..}}`
/// entries where the backend object is keyed by the tier type.
pub(super) fn parse_tier_names(body: &str) -> Result<BTreeSet<String>, RustfsClientError> {
let tiers = match serde_json::from_str::<Value>(body)
.map_err(|_| RustfsClientError::ParseResponseFailed)?
{
Value::Null => return Ok(BTreeSet::new()),
Value::Array(tiers) => tiers,
_ => return Err(RustfsClientError::ParseResponseFailed),
};
tiers
.iter()
.map(|tier| {
let tier_type = tier
.get("type")
.and_then(Value::as_str)
.ok_or(RustfsClientError::ParseResponseFailed)?;
tier.get(tier_type)
.and_then(|backend| backend.get("name"))
.and_then(Value::as_str)
.filter(|name| !name.is_empty())
.map(ToOwned::to_owned)
.ok_or(RustfsClientError::ParseResponseFailed)
})
.collect()
}

pub(super) fn sha256_hex(payload: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(payload);
Expand Down
45 changes: 42 additions & 3 deletions crates/rustfs-admin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ mod pool_ops;
mod s3_ops;
pub use s3_ops::{
BucketLifecycleAbortIncompleteMultipartUpload, BucketLifecycleConfiguration,
BucketLifecycleExpiration, BucketLifecycleRule, BucketLifecycleRuleStatus,
BucketObjectLockConfiguration, BucketObjectLockDefaultRetention, BucketObjectLockRetentionMode,
BucketLifecycleExpiration, BucketLifecycleNoncurrentVersionTransition, BucketLifecycleRule,
BucketLifecycleRuleStatus, BucketLifecycleTransition, BucketObjectLockConfiguration,
BucketObjectLockDefaultRetention, BucketObjectLockRetentionMode,
BucketObjectLockRetentionPeriod, BucketVersioningState, BucketVersioningUpdate,
canonicalize_bucket_lifecycle_xml,
};
Expand All @@ -54,6 +55,7 @@ const LIST_CANNED_POLICIES_PATH: &str = "/rustfs/admin/v3/list-canned-policies";
const ADD_CANNED_POLICY_PATH: &str = "/rustfs/admin/v3/add-canned-policy";
const INFO_CANNED_POLICY_PATH: &str = "/rustfs/admin/v3/info-canned-policy";
const SERVER_INFO_PATH: &str = "/rustfs/admin/v3/info";
const LIST_TIERS_PATH: &str = "/rustfs/admin/v3/tier";
const POOLS_LIST_PATH: &str = "/rustfs/admin/v3/pools/list";
const POOLS_STATUS_PATH: &str = "/rustfs/admin/v3/pools/status";
const POOLS_DECOMMISSION_PATH: &str = "/rustfs/admin/v3/pools/decommission";
Expand Down Expand Up @@ -557,9 +559,46 @@ impl RustfsAdminClient {

#[cfg(test)]
mod tests {
use super::helpers::build_canonical_query;
use super::helpers::{build_canonical_query, parse_tier_names};
use super::{RustfsClientError, StatusCode};

#[test]
fn tier_names_are_read_from_the_backend_object_and_secrets_are_ignored() {
// Shape of GET /rustfs/admin/v3/tier from RustFS 1.0.2-preview.2 (credentials replaced).
let body = r#"[
{"type":"s3","s3":{"name":"COLD","endpoint":"https://s3.example.com","accessKey":"AK","secretKey":"SK","bucket":"cold","prefix":"hot/","region":"us-1","storageClass":""}},
{"type":"rustfs","rustfs":{"name":"ARCHIVE","endpoint":"https://archive.example.com","accessKey":"AK","secretKey":"SK","bucket":"archive"}}
]"#;

let names = parse_tier_names(body).expect("tier list parses");
assert_eq!(
names.into_iter().collect::<Vec<_>>(),
vec!["ARCHIVE".to_string(), "COLD".to_string()]
);
assert!(parse_tier_names("[]").expect("empty list").is_empty());
assert!(parse_tier_names("null").expect("null list").is_empty());
}

#[test]
fn tier_list_without_a_backend_name_is_rejected() {
for body in [
r#"{"type":"s3"}"#,
r#"[{"s3":{"name":"COLD"}}]"#,
r#"[{"type":"s3","s3":{}}]"#,
r#"[{"type":"s3","s3":{"name":""}}]"#,
r#"[{"type":"s3","minio":{"name":"COLD"}}]"#,
"not json",
] {
assert!(
matches!(
parse_tier_names(body),
Err(RustfsClientError::ParseResponseFailed)
),
"{body} must be rejected"
);
}
}

#[test]
fn canonical_query_uses_sigv4_uri_encoding_and_encoded_sort_order() {
let query = build_canonical_query(&[
Expand Down
121 changes: 117 additions & 4 deletions crates/rustfs-admin/src/s3_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,13 +392,27 @@ pub struct BucketLifecycleAbortIncompleteMultipartUpload {
pub days_after_initiation: i32,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BucketLifecycleTransition {
pub days: i32,
pub storage_class: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BucketLifecycleNoncurrentVersionTransition {
pub noncurrent_days: i32,
pub storage_class: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BucketLifecycleRule {
pub id: String,
pub status: BucketLifecycleRuleStatus,
pub prefix: String,
pub expiration: Option<BucketLifecycleExpiration>,
pub abort_incomplete_multipart_upload: Option<BucketLifecycleAbortIncompleteMultipartUpload>,
pub transition: Option<BucketLifecycleTransition>,
pub noncurrent_version_transition: Option<BucketLifecycleNoncurrentVersionTransition>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -432,9 +446,27 @@ impl BucketLifecycleConfiguration {
}
xml.push_str("</Filter><ID>");
xml.push_str(&escape_xml(&rule.id));
xml.push_str("</ID><Status>");
xml.push_str("</ID>");
// Element order follows RustFS's GetBucketLifecycleConfiguration response, which the
// ownership hash compares against after canonicalization.
if let Some(transition) = &rule.noncurrent_version_transition {
xml.push_str("<NoncurrentVersionTransition><NoncurrentDays>");
xml.push_str(&transition.noncurrent_days.to_string());
xml.push_str("</NoncurrentDays><StorageClass>");
xml.push_str(&escape_xml(&transition.storage_class));
xml.push_str("</StorageClass></NoncurrentVersionTransition>");
}
xml.push_str("<Status>");
xml.push_str(rule.status.as_str());
xml.push_str("</Status></Rule>");
xml.push_str("</Status>");
if let Some(transition) = &rule.transition {
xml.push_str("<Transition><Days>");
xml.push_str(&transition.days.to_string());
xml.push_str("</Days><StorageClass>");
xml.push_str(&escape_xml(&transition.storage_class));
xml.push_str("</StorageClass></Transition>");
}
xml.push_str("</Rule>");
}
xml.push_str("</LifecycleConfiguration>");
xml
Expand Down Expand Up @@ -1393,6 +1425,8 @@ mod tests {
prefix: "logs/<".to_string(),
expiration: Some(BucketLifecycleExpiration { days: 30 }),
abort_incomplete_multipart_upload: None,
transition: None,
noncurrent_version_transition: None,
},
BucketLifecycleRule {
id: "a".to_string(),
Expand All @@ -1404,6 +1438,8 @@ mod tests {
days_after_initiation: 1,
},
),
transition: None,
noncurrent_version_transition: None,
},
],
};
Expand All @@ -1424,6 +1460,8 @@ mod tests {
prefix: "logs/".to_string(),
expiration: Some(BucketLifecycleExpiration { days: 30 }),
abort_incomplete_multipart_upload: None,
transition: None,
noncurrent_version_transition: None,
},
BucketLifecycleRule {
id: "cleanup-incomplete-uploads".to_string(),
Expand All @@ -1435,6 +1473,8 @@ mod tests {
days_after_initiation: 1,
},
),
transition: None,
noncurrent_version_transition: None,
},
],
};
Expand All @@ -1454,6 +1494,79 @@ mod tests {
);
}

#[test]
fn lifecycle_xml_with_transitions_matches_rustfs_get_response_shape() {
let configuration = BucketLifecycleConfiguration {
rules: vec![
BucketLifecycleRule {
id: "to-cold".to_string(),
status: BucketLifecycleRuleStatus::Enabled,
prefix: "logs/".to_string(),
expiration: Some(BucketLifecycleExpiration { days: 30 }),
abort_incomplete_multipart_upload: None,
transition: Some(BucketLifecycleTransition {
days: 1,
storage_class: "COLD".to_string(),
}),
noncurrent_version_transition: None,
},
BucketLifecycleRule {
id: "noncurrent-to-cold".to_string(),
status: BucketLifecycleRuleStatus::Enabled,
prefix: String::new(),
expiration: None,
abort_incomplete_multipart_upload: None,
transition: None,
noncurrent_version_transition: Some(
BucketLifecycleNoncurrentVersionTransition {
noncurrent_days: 7,
storage_class: "COLD".to_string(),
},
),
},
],
};
// RustFS re-serializes stored rules in this element order regardless of the order sent.
let rustfs_get_fixture = concat!(
"<LifecycleConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">",
"<Rule><Filter></Filter><ID>noncurrent-to-cold</ID>",
"<NoncurrentVersionTransition><NoncurrentDays>7</NoncurrentDays>",
"<StorageClass>COLD</StorageClass></NoncurrentVersionTransition>",
"<Status>Enabled</Status></Rule>",
"<Rule><Expiration><Days>30</Days></Expiration>",
"<Filter><Prefix>logs/</Prefix></Filter><ID>to-cold</ID><Status>Enabled</Status>",
"<Transition><Days>1</Days><StorageClass>COLD</StorageClass></Transition>",
"</Rule></LifecycleConfiguration>"
);

assert_eq!(
canonicalize_bucket_lifecycle_xml(&configuration.to_xml()).unwrap(),
canonicalize_bucket_lifecycle_xml(rustfs_get_fixture).unwrap()
);
}

#[test]
fn lifecycle_xml_transition_allows_zero_days_and_escapes_storage_class() {
let configuration = BucketLifecycleConfiguration {
rules: vec![BucketLifecycleRule {
id: "now".to_string(),
status: BucketLifecycleRuleStatus::Enabled,
prefix: String::new(),
expiration: None,
abort_incomplete_multipart_upload: None,
transition: Some(BucketLifecycleTransition {
days: 0,
storage_class: "A&B".to_string(),
}),
noncurrent_version_transition: None,
}],
};

assert!(configuration.to_xml().contains(
"<Status>Enabled</Status><Transition><Days>0</Days><StorageClass>A&amp;B</StorageClass></Transition></Rule>"
));
}

#[test]
fn lifecycle_canonicalization_normalizes_transport_formatting() {
let compact = concat!(
Expand Down Expand Up @@ -1482,8 +1595,8 @@ mod tests {
);
let live = concat!(
"<LifecycleConfiguration><Rule><Filter></Filter><ID>all</ID>",
"<Status>Enabled</Status><Transition><Days>7</Days>",
"<StorageClass>WARM</StorageClass></Transition></Rule></LifecycleConfiguration>"
"<NoncurrentVersionExpiration><NoncurrentDays>7</NoncurrentDays>",
"</NoncurrentVersionExpiration><Status>Enabled</Status></Rule></LifecycleConfiguration>"
);

assert_ne!(
Expand Down
2 changes: 1 addition & 1 deletion deploy/rustfs-operator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ spec:
days: 30
```

Policy ConfigMaps and user Secrets must live in the Tenant namespace. `users[].credsSecret.name` selects the credentials Secret; when omitted, the operator falls back to a Secret named after `users[].name` for compatibility with existing manifests. Bucket versioning can be enabled or suspended. Object Lock automatically requires enabled versioning and cannot be disabled after activation; `objectLockConfiguration` manages an optional default `Governance` or `Compliance` retention period in days. Omitting the configuration leaves an existing rule unmanaged, while `state: Absent` removes only an operator-owned default rule. Bucket lifecycle supports expiration and incomplete multipart upload cleanup rules. Omission leaves lifecycle unmanaged; `state: Absent` deletes only a configuration whose live hash still matches the Operator's ownership record. The operator indexes references from Tenant specs, so creating or updating a referenced object enqueues every referencing Tenant without requiring or mutating labels or requiring write access to that object. Provisioned resources are retained when removed from the Tenant spec.
Policy ConfigMaps and user Secrets must live in the Tenant namespace. `users[].credsSecret.name` selects the credentials Secret; when omitted, the operator falls back to a Secret named after `users[].name` for compatibility with existing manifests. Bucket versioning can be enabled or suspended. Object Lock automatically requires enabled versioning and cannot be disabled after activation; `objectLockConfiguration` manages an optional default `Governance` or `Compliance` retention period in days. Omitting the configuration leaves an existing rule unmanaged, while `state: Absent` removes only an operator-owned default rule. Bucket lifecycle supports expiration, incomplete multipart upload cleanup, and transition of current or noncurrent versions to a remote tier registered in RustFS. Omission leaves lifecycle unmanaged; `state: Absent` deletes only a configuration whose live hash still matches the Operator's ownership record. The operator indexes references from Tenant specs, so creating or updating a referenced object enqueues every referencing Tenant without requiring or mutating labels or requiring write access to that object. Provisioned resources are retained when removed from the Tenant spec.

### RBAC Configuration

Expand Down
Loading