From ed71cb7ac84ef3726a556682b3fcbbd45d566a3c Mon Sep 17 00:00:00 2001 From: Vector Date: Fri, 16 Oct 2026 14:32:42 +0300 Subject: [PATCH] feat(provisioning): support transition actions in bucket lifecycle Bucket lifecycle rules accept `transition` (current versions) and `noncurrentVersionTransition` (noncurrent versions), both after zero or more days, matching RustFS validation. `storageClass` names a tier that is registered in RustFS; the operator checks it against RustFS tier-name rules (uppercase, not STANDARD or REDUCED_REDUNDANCY) and does not register tiers itself. Before applying a lifecycle that references tiers, the operator lists them via GET /rustfs/admin/v3/tier. A missing tier keeps the bucket Pending and requeues instead of failing on RustFS's generic 400, so a tier registered after the rule is picked up. Only tier names are read from that response. The admin client serializes the new elements in the order RustFS returns from GetBucketLifecycleConfiguration, so the canonical ownership hash of an applied configuration matches the live document. Rules without transitions serialize exactly as before. Closes #279 --- CHANGELOG.md | 2 + console-web/types/api.ts | 8 + crates/rustfs-admin/src/admin_ops.rs | 19 +- crates/rustfs-admin/src/helpers.rs | 29 ++ crates/rustfs-admin/src/lib.rs | 45 ++- crates/rustfs-admin/src/s3_ops.rs | 121 ++++++- deploy/rustfs-operator/README.md | 2 +- deploy/rustfs-operator/crds/tenant-crd.yaml | 38 ++- docs/operator-user-guide.md | 17 +- docs/operator-user-guide.zh-CN.md | 17 +- src/console/openapi.rs | 15 +- src/reconcile/provisioning.rs | 330 +++++++++++++++++++- src/sts/rustfs_client.rs | 5 +- src/types/v1alpha1.rs | 16 +- src/types/v1alpha1/provisioning.rs | 66 +++- 15 files changed, 701 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0bb5d7..3331562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/console-web/types/api.ts b/console-web/types/api.ts index d005a5f..78a860f 100644 --- a/console-web/types/api.ts +++ b/console-web/types/api.ts @@ -157,6 +157,14 @@ export interface BucketLifecycleRule { abortIncompleteMultipartUpload?: { daysAfterInitiation: number } + transition?: { + days: number + storageClass: string + } + noncurrentVersionTransition?: { + noncurrentDays: number + storageClass: string + } } export interface ProvisioningItemStatus { diff --git a/crates/rustfs-admin/src/admin_ops.rs b/crates/rustfs-admin/src/admin_ops.rs index 6f09dd6..1ac7079 100644 --- a/crates/rustfs-admin/src/admin_ops.rs +++ b/crates/rustfs-admin/src/admin_ops.rs @@ -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; @@ -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, RustfsClientError> { + let body = self + .send_admin_request("GET", LIST_TIERS_PATH, "", "", None) + .await?; + parse_tier_names(&body) + } + pub async fn server_info(&self) -> Result { let body = self .send_admin_request("GET", SERVER_INFO_PATH, "", "", None) diff --git a/crates/rustfs-admin/src/helpers.rs b/crates/rustfs-admin/src/helpers.rs index 0a94465..fb5b008 100644 --- a/crates/rustfs-admin/src/helpers.rs +++ b/crates/rustfs-admin/src/helpers.rs @@ -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; @@ -150,6 +152,33 @@ pub(super) fn extract_canned_policy_document(body: &str) -> Result", "": {"name": ..}}` +/// entries where the backend object is keyed by the tier type. +pub(super) fn parse_tier_names(body: &str) -> Result, RustfsClientError> { + let tiers = match serde_json::from_str::(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); diff --git a/crates/rustfs-admin/src/lib.rs b/crates/rustfs-admin/src/lib.rs index deda0e6..09a673b 100644 --- a/crates/rustfs-admin/src/lib.rs +++ b/crates/rustfs-admin/src/lib.rs @@ -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, }; @@ -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"; @@ -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!["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(&[ diff --git a/crates/rustfs-admin/src/s3_ops.rs b/crates/rustfs-admin/src/s3_ops.rs index d4080b0..ff43c3e 100644 --- a/crates/rustfs-admin/src/s3_ops.rs +++ b/crates/rustfs-admin/src/s3_ops.rs @@ -392,6 +392,18 @@ 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, @@ -399,6 +411,8 @@ pub struct BucketLifecycleRule { pub prefix: String, pub expiration: Option, pub abort_incomplete_multipart_upload: Option, + pub transition: Option, + pub noncurrent_version_transition: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -432,9 +446,27 @@ impl BucketLifecycleConfiguration { } xml.push_str(""); xml.push_str(&escape_xml(&rule.id)); - xml.push_str(""); + xml.push_str(""); + // 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(""); + xml.push_str(&transition.noncurrent_days.to_string()); + xml.push_str(""); + xml.push_str(&escape_xml(&transition.storage_class)); + xml.push_str(""); + } + xml.push_str(""); xml.push_str(rule.status.as_str()); - xml.push_str(""); + xml.push_str(""); + if let Some(transition) = &rule.transition { + xml.push_str(""); + xml.push_str(&transition.days.to_string()); + xml.push_str(""); + xml.push_str(&escape_xml(&transition.storage_class)); + xml.push_str(""); + } + xml.push_str(""); } xml.push_str(""); xml @@ -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(), @@ -1404,6 +1438,8 @@ mod tests { days_after_initiation: 1, }, ), + transition: None, + noncurrent_version_transition: None, }, ], }; @@ -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(), @@ -1435,6 +1473,8 @@ mod tests { days_after_initiation: 1, }, ), + transition: None, + noncurrent_version_transition: None, }, ], }; @@ -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!( + "", + "noncurrent-to-cold", + "7", + "COLD", + "Enabled", + "30", + "logs/to-coldEnabled", + "1COLD", + "" + ); + + 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( + "Enabled0A&B" + )); + } + #[test] fn lifecycle_canonicalization_normalizes_transport_formatting() { let compact = concat!( @@ -1482,8 +1595,8 @@ mod tests { ); let live = concat!( "all", - "Enabled7", - "WARM" + "7", + "Enabled" ); assert_ne!( diff --git a/deploy/rustfs-operator/README.md b/deploy/rustfs-operator/README.md index 6f3f8d4..908a986 100755 --- a/deploy/rustfs-operator/README.md +++ b/deploy/rustfs-operator/README.md @@ -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 diff --git a/deploy/rustfs-operator/crds/tenant-crd.yaml b/deploy/rustfs-operator/crds/tenant-crd.yaml index 1949502..f7cbbf9 100644 --- a/deploy/rustfs-operator/crds/tenant-crd.yaml +++ b/deploy/rustfs-operator/crds/tenant-crd.yaml @@ -1277,19 +1277,53 @@ spec: maxLength: 255 minLength: 1 type: string + noncurrentVersionTransition: + description: |- + Moves noncurrent object versions to a remote tier `noncurrentDays` after they become + noncurrent. Only meaningful on versioned buckets. + nullable: true + properties: + noncurrentDays: + format: int32 + minimum: 0.0 + type: integer + storageClass: + minLength: 1 + type: string + required: + - noncurrentDays + - storageClass + type: object status: enum: - Enabled - Disabled type: string + transition: + description: |- + Moves current object versions to a remote tier after `days`. `storageClass` names a tier + registered in RustFS (uppercase); RustFS rejects a lifecycle that references an unknown tier. + nullable: true + properties: + days: + format: int32 + minimum: 0.0 + type: integer + storageClass: + minLength: 1 + type: string + required: + - days + - storageClass + type: object required: - filter - id - status type: object x-kubernetes-validations: - - message: lifecycle rule must configure expiration or abortIncompleteMultipartUpload - rule: has(self.expiration) || has(self.abortIncompleteMultipartUpload) + - message: lifecycle rule must configure expiration, abortIncompleteMultipartUpload, transition, or noncurrentVersionTransition + rule: has(self.expiration) || has(self.abortIncompleteMultipartUpload) || has(self.transition) || has(self.noncurrentVersionTransition) maxItems: 1000 type: array x-kubernetes-list-map-keys: diff --git a/docs/operator-user-guide.md b/docs/operator-user-guide.md index 1f40b9d..f5350cf 100644 --- a/docs/operator-user-guide.md +++ b/docs/operator-user-guide.md @@ -961,7 +961,22 @@ The operator can create RustFS policies, users, and buckets after the Tenant wor Bucket versioning and Object Lock are declarative but conservative. `versioning: true` enables versioning. `versioning: false` keeps a never-versioned bucket unchanged or requests S3 `Suspended` after versioning has been enabled; omitting the field leaves versioning unmanaged unless Object Lock requires it. `objectLock: true` enables Object Lock after versioning is enabled. Object Lock cannot be disabled, and the Operator refuses `objectLock: false` or versioning suspension when the live bucket has Object Lock enabled. `objectLockConfiguration` manages the default retention rule: omit `state` for `Present`, specify `mode: Governance` or `Compliance` with `days`, or use `state: Absent` without `mode` and `days` to remove only the default rule. Omitting the entire field leaves a live default-retention rule unmanaged. The Operator adopts an identical live rule, but does not replace or remove a different rule unless its hash matches the last configuration recorded in status. Default retention applies to new object versions; it does not retroactively lock existing versions. -Each bucket may also declare `lifecycle.state: Present` with one or more S3 lifecycle rules. The current schema supports expiration after a positive number of days and aborting incomplete multipart uploads after a positive number of days; every rule has a unique ID, an `Enabled` or `Disabled` status, and an explicit prefix filter (use `prefix: ""` for all objects). `lifecycle.state: Absent` requests deletion and cannot include rules. The Operator adopts an identical live configuration, but never replaces or deletes a different configuration without a matching ownership hash in `status.provisioning.buckets`. Omitting `lifecycle` performs no lifecycle API calls and leaves the live configuration untouched. +Each bucket may also declare `lifecycle.state: Present` with one or more S3 lifecycle rules. The current schema supports expiration after a positive number of days, aborting incomplete multipart uploads after a positive number of days, `transition` of current versions after zero or more days, and `noncurrentVersionTransition` of noncurrent versions after zero or more days; every rule has a unique ID, an `Enabled` or `Disabled` status, and an explicit prefix filter (use `prefix: ""` for all objects). `lifecycle.state: Absent` requests deletion and cannot include rules. The Operator adopts an identical live configuration, but never replaces or deletes a different configuration without a matching ownership hash in `status.provisioning.buckets`. Omitting `lifecycle` performs no lifecycle API calls and leaves the live configuration untouched. + +Transition actions move data to a remote tier. `storageClass` is the name of a tier already registered in RustFS (for example with `rc ilm tier add`); tier names are uppercase, and `STANDARD` and `REDUCED_REDUNDANCY` are reserved. The Operator does not register tiers. Before applying a lifecycle with transition actions it checks that every referenced tier exists; while one is missing, the bucket stays `Pending` with a message naming the tier and the Operator retries, so the tier can be registered after the rule is added: + +```yaml +lifecycle: + state: Present + rules: + - id: to-cold + status: Enabled + filter: + prefix: "" + transition: + days: 7 + storageClass: COLD +``` Transient Kubernetes and RustFS admin/S3 failures (timeouts, 429, 5xx, connection errors, TLS not ready) leave provisioning items `Pending` and requeue instead of marking the Tenant `Failed`. Permanent 4xx configuration errors still fail and wait for a spec or object change. diff --git a/docs/operator-user-guide.zh-CN.md b/docs/operator-user-guide.zh-CN.md index 02f224b..f24e35d 100644 --- a/docs/operator-user-guide.zh-CN.md +++ b/docs/operator-user-guide.zh-CN.md @@ -905,7 +905,22 @@ Operator 可以在 Tenant workload Ready 后自动创建 RustFS policy、user Bucket 版本控制和 Object Lock 采用声明式但保守的调和策略。`versioning: true` 启用版本控制。`versioning: false` 会让从未启用版本控制的 bucket 保持原状,或在版本控制已启用后请求 S3 `Suspended`;省略该字段时保持现状,除非 Object Lock 要求先启用版本控制。`objectLock: true` 会在版本控制启用后开启 Object Lock。Object Lock 不可关闭;当线上 bucket 已启用 Object Lock 时,Operator 会拒绝 `objectLock: false` 或暂停版本控制。`objectLockConfiguration` 管理默认保留规则:省略 `state` 等同于 `Present`,此时必须设置 `mode: Governance` 或 `Compliance` 以及 `days`;`state: Absent` 必须省略 `mode` 和 `days`,仅移除默认规则。完全省略 `objectLockConfiguration` 时,不管理已有默认保留规则。Operator 可以接管与声明完全相同的线上规则,但只有线上配置哈希仍与 status 中最后一次应用的配置一致时,才会替换或移除不同规则。默认保留策略只作用于之后创建的对象版本,不会追溯锁定已有版本。 -每个 bucket 还可以通过 `lifecycle.state: Present` 声明一个或多个 S3 生命周期规则。当前 schema 支持按正整数天数过期对象,以及按正整数天数清理未完成的分片上传;每条规则必须有唯一 ID、`Enabled` 或 `Disabled` 状态,并显式指定 prefix filter(`prefix: ""` 表示全部对象)。`lifecycle.state: Absent` 表示请求删除,且不能包含规则。Operator 可以接管与声明完全相同的现有配置,但没有 `status.provisioning.buckets` 中匹配的所有权哈希时,不会覆盖或删除不同的现有配置。省略 `lifecycle` 时不会调用 Lifecycle API,也不会改动现有配置。 +每个 bucket 还可以通过 `lifecycle.state: Present` 声明一个或多个 S3 生命周期规则。当前 schema 支持按正整数天数过期对象、按正整数天数清理未完成的分片上传、通过 `transition` 在零天或更多天后迁移当前版本,以及通过 `noncurrentVersionTransition` 在零天或更多天后迁移非当前版本;每条规则必须有唯一 ID、`Enabled` 或 `Disabled` 状态,并显式指定 prefix filter(`prefix: ""` 表示全部对象)。`lifecycle.state: Absent` 表示请求删除,且不能包含规则。Operator 可以接管与声明完全相同的现有配置,但没有 `status.provisioning.buckets` 中匹配的所有权哈希时,不会覆盖或删除不同的现有配置。省略 `lifecycle` 时不会调用 Lifecycle API,也不会改动现有配置。 + +迁移类操作会把数据移动到远程层(tier)。`storageClass` 是 RustFS 中已注册的 tier 名称(例如通过 `rc ilm tier add` 注册);tier 名称必须为大写,`STANDARD` 和 `REDUCED_REDUNDANCY` 为保留名称。Operator 不负责注册 tier。在应用包含迁移操作的生命周期配置之前,Operator 会检查所有被引用的 tier 是否存在;如果有 tier 尚未注册,bucket 会保持 `Pending` 状态并在消息中列出该 tier,Operator 会持续重试,因此可以在添加规则之后再注册 tier: + +```yaml +lifecycle: + state: Present + rules: + - id: to-cold + status: Enabled + filter: + prefix: "" + transition: + days: 7 + storageClass: COLD +``` Kubernetes 或 RustFS 管理/S3 API 的瞬时失败(超时、429、5xx、连接错误、TLS 未就绪)会把 provisioning 条目保持为 `Pending` 并重新入队,而不会把 Tenant 标为 `Failed`。永久性 4xx 配置错误仍会失败并等待 spec 或对象变更。 diff --git a/src/console/openapi.rs b/src/console/openapi.rs index ea7437e..23581f6 100644 --- a/src/console/openapi.rs +++ b/src/console/openapi.rs @@ -56,11 +56,12 @@ use crate::console::models::topology::{ }; use crate::types::v1alpha1::provisioning::{ BucketAnonymousAccess, BucketLifecycleAbortIncompleteMultipartUpload, - BucketLifecycleExpiration, BucketLifecycleRule, BucketLifecycleRuleFilter, - BucketLifecycleRuleStatus, BucketLifecycleSpec, BucketLifecycleState, - BucketObjectLockConfiguration, BucketObjectLockConfigurationState, - BucketObjectLockRetentionMode, ConfigMapKeyReference, PolicyDocumentSource, ProvisioningBucket, - ProvisioningDeletionPolicy, ProvisioningPolicy, ProvisioningUser, UserCredentialsSecretRef, + BucketLifecycleExpiration, BucketLifecycleNoncurrentVersionTransition, BucketLifecycleRule, + BucketLifecycleRuleFilter, BucketLifecycleRuleStatus, BucketLifecycleSpec, + BucketLifecycleState, BucketLifecycleTransition, BucketObjectLockConfiguration, + BucketObjectLockConfigurationState, BucketObjectLockRetentionMode, ConfigMapKeyReference, + PolicyDocumentSource, ProvisioningBucket, ProvisioningDeletionPolicy, ProvisioningPolicy, + ProvisioningUser, UserCredentialsSecretRef, }; use crate::types::v1alpha1::status::provisioning::{ BucketVersioningStatus, ProvisioningBucketStatus, ProvisioningItemState, @@ -143,6 +144,8 @@ use crate::types::v1alpha1::status::provisioning::{ BucketLifecycleRuleFilter, BucketLifecycleExpiration, BucketLifecycleAbortIncompleteMultipartUpload, + BucketLifecycleTransition, + BucketLifecycleNoncurrentVersionTransition, CreateTenantRequest, CreatePoolRequest, PoolInfo, @@ -792,6 +795,8 @@ mod tests { assert!(schemas.contains_key("ProvisioningStatus")); assert!(schemas.contains_key("ProvisioningBucketStatus")); assert!(schemas.contains_key("BucketLifecycleSpec")); + assert!(schemas.contains_key("BucketLifecycleTransition")); + assert!(schemas.contains_key("BucketLifecycleNoncurrentVersionTransition")); assert!(schemas.contains_key("BucketObjectLockConfiguration")); assert!(schemas.contains_key("UserCredentialsSecretRef")); assert_eq!( diff --git a/src/reconcile/provisioning.rs b/src/reconcile/provisioning.rs index 9909f0a..6380c48 100644 --- a/src/reconcile/provisioning.rs +++ b/src/reconcile/provisioning.rs @@ -17,8 +17,10 @@ use crate::sts::rustfs_client::{ BucketLifecycleAbortIncompleteMultipartUpload as AdminLifecycleAbort, BucketLifecycleConfiguration as AdminLifecycleConfiguration, BucketLifecycleExpiration as AdminLifecycleExpiration, + BucketLifecycleNoncurrentVersionTransition as AdminLifecycleNoncurrentVersionTransition, BucketLifecycleRule as AdminLifecycleRule, BucketLifecycleRuleStatus as AdminLifecycleRuleStatus, + BucketLifecycleTransition as AdminLifecycleTransition, BucketObjectLockConfiguration as AdminObjectLockConfiguration, BucketObjectLockDefaultRetention as AdminObjectLockDefaultRetention, BucketObjectLockRetentionMode as AdminObjectLockRetentionMode, @@ -2784,6 +2786,16 @@ async fn reconcile_bucket_lifecycle( ), LifecycleReconcileAction::Apply => { if let Some(configuration) = desired.as_ref() { + if let Some(waiting) = + lifecycle_tiers_pending(run, client, previous, &base_item, configuration).await + { + return finalize_bucket_lifecycle_status( + waiting, + previous, + desired_hash, + false, + ); + } match client .put_bucket_lifecycle_configuration(&bucket.name, configuration) .await @@ -2876,6 +2888,71 @@ async fn reconcile_bucket_lifecycle( finalize_bucket_lifecycle_status(item, previous, desired_hash, applied) } +/// Transition rules name tiers that are registered outside the operator, often after the +/// Tenant is provisioned. RustFS rejects a lifecycle that references an unknown tier with a +/// generic 400, which would leave the bucket `Failed` until the spec changes, so a missing tier +/// is reported as `Pending` and retried instead. +async fn lifecycle_tiers_pending( + run: &mut ProvisioningRun<'_>, + client: &RustfsAdminClient, + previous: Option<&ProvisioningBucketStatus>, + base_item: &ProvisioningItemStatus, + configuration: &AdminLifecycleConfiguration, +) -> Option { + let referenced = configuration + .rules + .iter() + .flat_map(|rule| { + rule.transition + .iter() + .map(|transition| transition.storage_class.as_str()) + .chain( + rule.noncurrent_version_transition + .iter() + .map(|transition| transition.storage_class.as_str()), + ) + }) + .collect::>(); + if referenced.is_empty() { + return None; + } + + let registered = match client.list_tier_names().await { + Ok(names) => names, + Err(error) => { + return Some(bucket_item_from_admin_error( + run, + previous, + base_item, + Reason::BucketLifecycleApplyFailed, + error, + "failed to list RustFS tiers referenced by bucket lifecycle", + )); + } + }; + let missing = referenced + .into_iter() + .filter(|name| !registered.contains(*name)) + .collect::>(); + if missing.is_empty() { + return None; + } + + let message = format!( + "waiting for RustFS tier(s) {} referenced by bucket lifecycle", + missing.join(", ") + ); + run.request_retry(message.clone()); + Some(replace_bucket_item_state( + run, + previous, + base_item, + ProvisioningItemState::Pending, + Reason::ProvisioningPending, + message, + )) +} + fn lifecycle_configuration( spec: &BucketLifecycleSpec, ) -> Result, String> { @@ -2924,9 +3001,13 @@ fn lifecycle_configuration( rule.id )); } - if rule.expiration.is_none() && rule.abort_incomplete_multipart_upload.is_none() { + if rule.expiration.is_none() + && rule.abort_incomplete_multipart_upload.is_none() + && rule.transition.is_none() + && rule.noncurrent_version_transition.is_none() + { return Err(format!( - "bucket lifecycle rule '{}' must configure expiration or abortIncompleteMultipartUpload", + "bucket lifecycle rule '{}' must configure expiration, abortIncompleteMultipartUpload, transition, or noncurrentVersionTransition", rule.id )); } @@ -2951,6 +3032,29 @@ fn lifecycle_configuration( )); } + if let Some(transition) = rule.transition.as_ref() { + if transition.days < 0 { + return Err(format!( + "bucket lifecycle rule '{}' transition.days must not be negative", + rule.id + )); + } + validate_lifecycle_storage_class(&rule.id, "transition", &transition.storage_class)?; + } + if let Some(transition) = rule.noncurrent_version_transition.as_ref() { + if transition.noncurrent_days < 0 { + return Err(format!( + "bucket lifecycle rule '{}' noncurrentVersionTransition.noncurrentDays must not be negative", + rule.id + )); + } + validate_lifecycle_storage_class( + &rule.id, + "noncurrentVersionTransition", + &transition.storage_class, + )?; + } + rules.push(AdminLifecycleRule { id: rule.id.clone(), status: match rule.status { @@ -2967,11 +3071,56 @@ fn lifecycle_configuration( days_after_initiation: value.days_after_initiation, }, ), + transition: rule + .transition + .as_ref() + .map(|value| AdminLifecycleTransition { + days: value.days, + storage_class: value.storage_class.clone(), + }), + noncurrent_version_transition: rule.noncurrent_version_transition.as_ref().map( + |value| AdminLifecycleNoncurrentVersionTransition { + noncurrent_days: value.noncurrent_days, + storage_class: value.storage_class.clone(), + }, + ), }); } Ok(Some(AdminLifecycleConfiguration { rules })) } +/// Mirrors RustFS tier-name rules so a misspelled tier fails with a clear message instead of a +/// generic `invalid tier` response from PutBucketLifecycleConfiguration. +fn validate_lifecycle_storage_class( + rule_id: &str, + field: &str, + storage_class: &str, +) -> Result<(), String> { + if storage_class.is_empty() { + return Err(format!( + "bucket lifecycle rule '{rule_id}' {field}.storageClass must name a RustFS tier" + )); + } + if !is_valid_xml_text(storage_class) { + return Err(format!( + "bucket lifecycle rule '{rule_id}' {field}.storageClass contains characters that cannot be represented in S3 XML" + )); + } + if storage_class != storage_class.to_uppercase() { + return Err(format!( + "bucket lifecycle rule '{rule_id}' {field}.storageClass '{storage_class}' must be uppercase like RustFS tier names" + )); + } + if storage_class.eq_ignore_ascii_case("STANDARD") + || storage_class.eq_ignore_ascii_case("REDUCED_REDUNDANCY") + { + return Err(format!( + "bucket lifecycle rule '{rule_id}' {field}.storageClass '{storage_class}' is a reserved storage class, not a tier" + )); + } + Ok(()) +} + fn lifecycle_desired_hash(spec: &BucketLifecycleSpec) -> Option { lifecycle_configuration(spec) .ok() @@ -3603,7 +3752,9 @@ fn reason_from_str(reason: &str) -> Reason { #[cfg(test)] mod tests { use super::*; - use crate::types::v1alpha1::provisioning::ConfigMapKeyReference; + use crate::types::v1alpha1::provisioning::{ + BucketLifecycleNoncurrentVersionTransition, ConfigMapKeyReference, + }; use axum::{ Router, body::Body, @@ -3637,6 +3788,8 @@ mod tests { #[derive(Clone, Default)] struct LifecycleCapture { live: Arc>>, + tiers: Arc>, + tier_count: Arc, put_body: Arc>, get_count: Arc, put_count: Arc, @@ -3910,11 +4063,17 @@ mod tests { } } + async fn lifecycle_tier_handler(State(capture): State) -> Response { + capture.tier_count.fetch_add(1, Ordering::SeqCst); + (StatusCode::OK, capture.tiers.lock().await.clone()).into_response() + } + async fn lifecycle_test_client( capture: LifecycleCapture, ) -> (RustfsAdminClient, tokio::task::JoinHandle<()>) { let router = Router::new() .route("/app-data", any(lifecycle_bucket_handler)) + .route("/rustfs/admin/v3/tier", get(lifecycle_tier_handler)) .with_state(capture); let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) .await @@ -3947,6 +4106,22 @@ mod tests { .expect("lifecycle bucket should deserialize") } + fn transition_lifecycle_bucket(days: i32, storage_class: &str) -> ProvisioningBucket { + serde_json::from_value(serde_json::json!({ + "name": "app-data", + "lifecycle": { + "state": "Present", + "rules": [{ + "id": "to-cold", + "status": "Enabled", + "filter": {"prefix": ""}, + "transition": {"days": days, "storageClass": storage_class} + }] + } + })) + .expect("transition lifecycle bucket should deserialize") + } + fn absent_lifecycle_bucket() -> ProvisioningBucket { serde_json::from_value(serde_json::json!({ "name": "app-data", @@ -6407,6 +6582,11 @@ mod tests { assert_eq!(capture.get_count.load(Ordering::SeqCst), 1); assert_eq!(capture.put_count.load(Ordering::SeqCst), 1); assert_eq!(capture.delete_count.load(Ordering::SeqCst), 0); + assert_eq!( + capture.tier_count.load(Ordering::SeqCst), + 0, + "rules without transitions must not query RustFS tiers" + ); assert_eq!( item.lifecycle_desired_hash, item.lifecycle_last_applied_hash @@ -6676,6 +6856,150 @@ mod tests { assert!(lifecycle_configuration(zero_days.lifecycle.as_ref().expect("lifecycle")).is_err()); } + #[test] + fn lifecycle_configuration_validates_transition_fields() { + let valid = transition_lifecycle_bucket(0, "COLD"); + let configuration = lifecycle_configuration(valid.lifecycle.as_ref().expect("lifecycle")) + .expect("zero-day transition is valid") + .expect("Present lifecycle"); + assert_eq!( + configuration.rules[0].transition, + Some(AdminLifecycleTransition { + days: 0, + storage_class: "COLD".to_string(), + }) + ); + + for (days, storage_class) in [ + (-1, "COLD"), + (1, ""), + (1, "cold"), + (1, "STANDARD"), + (1, "reduced_redundancy"), + (1, "BAD\u{0}TIER"), + ] { + let bucket = transition_lifecycle_bucket(days, storage_class); + assert!( + lifecycle_configuration(bucket.lifecycle.as_ref().expect("lifecycle")).is_err(), + "transition days={days} storageClass={storage_class:?} must be rejected" + ); + } + + let mut noncurrent = transition_lifecycle_bucket(1, "COLD"); + let rule = &mut noncurrent.lifecycle.as_mut().expect("lifecycle").rules[0]; + rule.transition = None; + rule.noncurrent_version_transition = Some(BucketLifecycleNoncurrentVersionTransition { + noncurrent_days: -1, + storage_class: "COLD".to_string(), + }); + assert!( + lifecycle_configuration(noncurrent.lifecycle.as_ref().expect("lifecycle")).is_err() + ); + let rule = &mut noncurrent.lifecycle.as_mut().expect("lifecycle").rules[0]; + rule.noncurrent_version_transition = Some(BucketLifecycleNoncurrentVersionTransition { + noncurrent_days: 0, + storage_class: "COLD".to_string(), + }); + assert!( + lifecycle_configuration(noncurrent.lifecycle.as_ref().expect("lifecycle")).is_ok(), + "RustFS accepts a zero-day noncurrent transition" + ); + let rule = &mut noncurrent.lifecycle.as_mut().expect("lifecycle").rules[0]; + rule.noncurrent_version_transition = Some(BucketLifecycleNoncurrentVersionTransition { + noncurrent_days: 7, + storage_class: "COLD".to_string(), + }); + let configuration = + lifecycle_configuration(noncurrent.lifecycle.as_ref().expect("lifecycle")) + .expect("noncurrent transition is valid") + .expect("Present lifecycle"); + assert_eq!( + configuration.rules[0].noncurrent_version_transition, + Some(AdminLifecycleNoncurrentVersionTransition { + noncurrent_days: 7, + storage_class: "COLD".to_string(), + }) + ); + } + + #[tokio::test] + async fn transition_bucket_lifecycle_is_applied_when_live_configuration_is_missing() { + let capture = LifecycleCapture::default(); + *capture.tiers.lock().await = r#"[{"type":"s3","s3":{"name":"COLD"}}]"#.to_string(); + let (client, server) = lifecycle_test_client(capture.clone()).await; + let ctx = empty_kube_context(); + let user = provisioning_user("app-user", "app-user-secret", "readwrite"); + let tenant = provisioning_test_tenant(user, ProvisioningStatus::default()); + let mut run = empty_run(&ctx, &tenant); + + let item = + reconcile_bucket(&mut run, &client, &transition_lifecycle_bucket(1, "COLD")).await; + + assert_eq!(item.state, ProvisioningItemState::Ready.as_str()); + assert_eq!(capture.tier_count.load(Ordering::SeqCst), 1); + assert_eq!(capture.put_count.load(Ordering::SeqCst), 1); + let body = capture.put_body.lock().await; + assert!(body.contains( + "Enabled1COLD" + )); + server.abort(); + } + + #[tokio::test] + async fn transition_bucket_lifecycle_waits_for_an_unregistered_tier() { + let capture = LifecycleCapture::default(); + *capture.tiers.lock().await = r#"[{"type":"s3","s3":{"name":"WARM"}}]"#.to_string(); + let (client, server) = lifecycle_test_client(capture.clone()).await; + let ctx = empty_kube_context(); + let user = provisioning_user("app-user", "app-user-secret", "readwrite"); + let tenant = provisioning_test_tenant(user, ProvisioningStatus::default()); + let mut run = empty_run(&ctx, &tenant); + + let item = + reconcile_bucket(&mut run, &client, &transition_lifecycle_bucket(1, "COLD")).await; + + assert_eq!(item.state, ProvisioningItemState::Pending.as_str()); + assert!( + item.message + .as_deref() + .is_some_and(|message| message.contains("COLD")) + ); + assert_eq!(capture.put_count.load(Ordering::SeqCst), 0); + assert!(run.retry.is_some(), "a missing tier must requeue"); + assert!(item.lifecycle_last_applied_hash.is_none()); + server.abort(); + } + + #[tokio::test] + async fn rustfs_shaped_live_transition_lifecycle_is_adopted_without_write() { + // Shape returned by RustFS GetBucketLifecycleConfiguration for an operator-applied rule. + let live = concat!( + "", + "", + "to-coldEnabled", + "1COLD", + "" + ); + let capture = LifecycleCapture::default(); + *capture.live.lock().await = Some(live.to_string()); + let (client, server) = lifecycle_test_client(capture.clone()).await; + let ctx = empty_kube_context(); + let user = provisioning_user("app-user", "app-user-secret", "readwrite"); + let tenant = provisioning_test_tenant(user, ProvisioningStatus::default()); + let mut run = empty_run(&ctx, &tenant); + + let item = + reconcile_bucket(&mut run, &client, &transition_lifecycle_bucket(1, "COLD")).await; + + assert_eq!(item.state, ProvisioningItemState::Ready.as_str()); + assert_eq!(capture.put_count.load(Ordering::SeqCst), 0); + assert_eq!( + item.lifecycle_desired_hash, + item.lifecycle_last_applied_hash + ); + server.abort(); + } + #[tokio::test] async fn anonymous_download_policy_is_applied_when_live_policy_is_missing() { let capture = PolicyApplyCapture::default(); diff --git a/src/sts/rustfs_client.rs b/src/sts/rustfs_client.rs index e7a03b1..b758226 100644 --- a/src/sts/rustfs_client.rs +++ b/src/sts/rustfs_client.rs @@ -22,8 +22,9 @@ use crate::cluster_dns; pub use rustfs_admin::{ BucketLifecycleAbortIncompleteMultipartUpload, BucketLifecycleConfiguration, - BucketLifecycleExpiration, BucketLifecycleRule, BucketLifecycleRuleStatus, - BucketObjectLockConfiguration, BucketObjectLockDefaultRetention, BucketObjectLockRetentionMode, + BucketLifecycleExpiration, BucketLifecycleNoncurrentVersionTransition, BucketLifecycleRule, + BucketLifecycleRuleStatus, BucketLifecycleTransition, BucketObjectLockConfiguration, + BucketObjectLockDefaultRetention, BucketObjectLockRetentionMode, BucketObjectLockRetentionPeriod, BucketVersioningState, BucketVersioningUpdate, CreateBucketResult, RustfsClientError, RustfsCredentials, RustfsErasureBackend, RustfsErasureSetInfo, RustfsPoolDecommissionInfo, RustfsPoolListItem, RustfsPoolStatus, diff --git a/src/types/v1alpha1.rs b/src/types/v1alpha1.rs index 1601fae..ec2051d 100755 --- a/src/types/v1alpha1.rs +++ b/src/types/v1alpha1.rs @@ -429,9 +429,23 @@ mod tenant_provisioning_crd_tests { lifecycle["x-kubernetes-validations"][0]["message"], json!("Present lifecycle requires rules and Absent lifecycle forbids rules") ); + assert_eq!( + lifecycle["properties"]["rules"]["items"]["properties"]["transition"]["properties"] + ["days"]["minimum"] + .as_f64(), + Some(0.0) + ); + assert_eq!( + lifecycle["properties"]["rules"]["items"]["properties"]["noncurrentVersionTransition"] + ["properties"]["noncurrentDays"]["minimum"] + .as_f64(), + Some(0.0) + ); assert_eq!( lifecycle["properties"]["rules"]["items"]["x-kubernetes-validations"][0]["message"], - json!("lifecycle rule must configure expiration or abortIncompleteMultipartUpload") + json!( + "lifecycle rule must configure expiration, abortIncompleteMultipartUpload, transition, or noncurrentVersionTransition" + ) ); let object_lock = &spec["properties"]["buckets"]["items"]["properties"]["objectLockConfiguration"]; diff --git a/src/types/v1alpha1/provisioning.rs b/src/types/v1alpha1/provisioning.rs index e141ba4..69b8260 100644 --- a/src/types/v1alpha1/provisioning.rs +++ b/src/types/v1alpha1/provisioning.rs @@ -208,9 +208,33 @@ pub struct BucketLifecycleAbortIncompleteMultipartUpload { pub days_after_initiation: i32, } +/// Moves current object versions to a remote tier after `days`. `storageClass` names a tier +/// registered in RustFS (uppercase); RustFS rejects a lifecycle that references an unknown tier. #[derive(Deserialize, Serialize, Clone, Debug, KubeSchema, ToSchema, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -#[x_kube(validation = Rule::new("has(self.expiration) || has(self.abortIncompleteMultipartUpload)").message("lifecycle rule must configure expiration or abortIncompleteMultipartUpload"))] +pub struct BucketLifecycleTransition { + #[schemars(range(min = 0))] + pub days: i32, + + #[schemars(length(min = 1))] + pub storage_class: String, +} + +/// Moves noncurrent object versions to a remote tier `noncurrentDays` after they become +/// noncurrent. Only meaningful on versioned buckets. +#[derive(Deserialize, Serialize, Clone, Debug, KubeSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BucketLifecycleNoncurrentVersionTransition { + #[schemars(range(min = 0))] + pub noncurrent_days: i32, + + #[schemars(length(min = 1))] + pub storage_class: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, KubeSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[x_kube(validation = Rule::new("has(self.expiration) || has(self.abortIncompleteMultipartUpload) || has(self.transition) || has(self.noncurrentVersionTransition)").message("lifecycle rule must configure expiration, abortIncompleteMultipartUpload, transition, or noncurrentVersionTransition"))] pub struct BucketLifecycleRule { #[schemars(length(min = 1, max = MAX_BUCKET_LIFECYCLE_RULE_ID_LENGTH))] pub id: String, @@ -224,6 +248,12 @@ pub struct BucketLifecycleRule { #[serde(default, skip_serializing_if = "Option::is_none")] pub abort_incomplete_multipart_upload: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transition: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub noncurrent_version_transition: Option, } #[derive(Deserialize, Serialize, Clone, Debug, KubeSchema, ToSchema, PartialEq, Eq)] @@ -457,6 +487,40 @@ mod tests { ); } + #[test] + fn bucket_lifecycle_deserializes_transition_actions() { + let bucket: super::ProvisioningBucket = serde_json::from_value(serde_json::json!({ + "name": "app-data", + "lifecycle": { + "state": "Present", + "rules": [{ + "id": "to-cold", + "status": "Enabled", + "filter": {"prefix": ""}, + "transition": {"days": 1, "storageClass": "COLD"}, + "noncurrentVersionTransition": {"noncurrentDays": 7, "storageClass": "COLD"} + }] + } + })) + .expect("transition lifecycle should deserialize"); + let rule = &bucket.lifecycle.expect("lifecycle is present").rules[0]; + assert_eq!( + rule.transition, + Some(super::BucketLifecycleTransition { + days: 1, + storage_class: "COLD".to_string(), + }) + ); + assert_eq!( + rule.noncurrent_version_transition, + Some(super::BucketLifecycleNoncurrentVersionTransition { + noncurrent_days: 7, + storage_class: "COLD".to_string(), + }) + ); + assert!(rule.expiration.is_none()); + } + #[test] fn bucket_object_lock_configuration_preserves_issue_yaml_shape() { let bucket: super::ProvisioningBucket = serde_json::from_value(serde_json::json!({